feat(notifications): täglicher Lauf über SMTP und Apprise

- Kanäle SMTP (HTML-Mail mit Logos als CID-Anhang) und Apprise, beide
  blockierenden Bibliotheken laufen in einem Thread
- Vier Anlässe: Fälligkeiten im Vorlauf, Kündigungsfristen in drei Stufen,
  überschrittene Budgets je Monat, Vertragsverlängerungen im Folgemonat
- APScheduler im Anwendungsprozess, täglich 07:00 Europe/Berlin, räumt zugleich
  abgelaufene Sitzungen auf
- Duplikatsschutz über den Zieltag statt den Versandtag; fehlgeschlagener
  Versand wird beim nächsten Lauf erneut versucht
- Endpunkte für Regeln, Protokoll, Testversand und sofortigen Lauf
- Einstellungsseite mit Einrichtungsstand, Regelpflege und Protokoll
- 25 neue Backend-Tests (260 gesamt), 9 neue Frontend-Tests (74 gesamt)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
moneyfy
2026-09-09 17:04:30 +02:00
co-authored by Claude Opus 5
parent 0adf154049
commit 54c59c9f71
17 changed files with 2500 additions and 1 deletions
+196
View File
@@ -0,0 +1,196 @@
"""Versandkanäle für Benachrichtigungen: SMTP und Apprise.
Beide Bibliotheken arbeiten blockierend; die Aufrufe laufen deshalb in einem
Thread, damit der Scheduler-Job den Event-Loop nicht anhält. Ein Kanal wirft
`ChannelError`, wenn der Versand fehlschlägt der Aufrufer protokolliert das.
"""
import asyncio
import logging
import smtplib
from dataclasses import dataclass, field
from email.message import EmailMessage
from email.utils import make_msgid
from pathlib import Path
from typing import Protocol, runtime_checkable
from app.core.config import settings
from app.models.enums import NotificationChannel
logger = logging.getLogger(__name__)
SMTP_TIMEOUT_SECONDS = 20
class ChannelError(RuntimeError):
"""Der Versand über einen Kanal ist fehlgeschlagen."""
@dataclass(frozen=True, slots=True)
class Attachment:
"""Ein eingebettetes Bild, das per Content-ID in der HTML-Mail steckt."""
cid: str
"""Ohne spitze Klammern im HTML als `cid:<wert>` referenziert."""
path: Path
mime: str
@dataclass(slots=True)
class Notification:
"""Eine fertig formulierte Nachricht."""
subject: str
text: str
html: str | None = None
attachments: list[Attachment] = field(default_factory=list)
@runtime_checkable
class Channel(Protocol):
"""Ein Weg, auf dem eine Nachricht den Nutzer erreicht."""
kind: NotificationChannel
def is_configured(self) -> bool:
"""False, wenn die nötigen Einstellungen fehlen der Kanal wird übersprungen."""
...
async def send(self, notification: Notification, target: str | None = None) -> None: ...
# --- SMTP ----------------------------------------------------------------------
class SmtpChannel:
"""Mailversand über einen SMTP-Server."""
kind = NotificationChannel.SMTP
def is_configured(self) -> bool:
return bool(settings.smtp_host and settings.smtp_from)
async def send(self, notification: Notification, target: str | None = None) -> None:
if not self.is_configured():
raise ChannelError("SMTP ist nicht konfiguriert (SMTP_HOST und SMTP_FROM fehlen).")
empfaenger = target or settings.smtp_from
if not empfaenger:
raise ChannelError("Kein Empfänger angegeben.")
nachricht = self._build(notification, empfaenger)
try:
await asyncio.to_thread(self._deliver, nachricht)
except ChannelError:
raise
except Exception as exc:
raise ChannelError(f"Mailversand fehlgeschlagen: {exc}") from exc
def _build(self, notification: Notification, empfaenger: str) -> EmailMessage:
"""Baut eine Mail mit Text- und HTML-Teil sowie eingebetteten Logos."""
nachricht = EmailMessage()
nachricht["Subject"] = notification.subject
nachricht["From"] = settings.smtp_from or ""
nachricht["To"] = empfaenger
nachricht.set_content(notification.text)
if notification.html is None:
return nachricht
html = notification.html
anhaenge: list[tuple[Attachment, str]] = []
for anhang in notification.attachments:
# Für jedes Bild eine echte Message-ID erzeugen und im HTML einsetzen.
message_id = make_msgid()
html = html.replace(f"cid:{anhang.cid}", f"cid:{message_id[1:-1]}")
anhaenge.append((anhang, message_id))
nachricht.add_alternative(html, subtype="html")
html_teil = nachricht.get_payload()[-1]
for anhang, message_id in anhaenge:
try:
inhalt = anhang.path.read_bytes()
except OSError:
logger.debug("Anhang %s konnte nicht gelesen werden.", anhang.path)
continue
haupttyp, _, untertyp = anhang.mime.partition("/")
html_teil.add_related(inhalt, maintype=haupttyp, subtype=untertyp, cid=message_id)
return nachricht
def _deliver(self, nachricht: EmailMessage) -> None:
"""Blockierender Teil des Versands."""
host = settings.smtp_host or ""
port = settings.smtp_port
if settings.smtp_use_ssl:
verbindung = smtplib.SMTP_SSL(host, port, timeout=SMTP_TIMEOUT_SECONDS)
else:
verbindung = smtplib.SMTP(host, port, timeout=SMTP_TIMEOUT_SECONDS)
with verbindung as server:
if settings.smtp_use_tls and not settings.smtp_use_ssl:
server.starttls()
if settings.smtp_user and settings.smtp_password:
server.login(settings.smtp_user, settings.smtp_password)
server.send_message(nachricht)
# --- Apprise -------------------------------------------------------------------
class AppriseChannel:
"""Versand über Apprise deckt ntfy, Gotify, Matrix, Discord und viele mehr ab."""
kind = NotificationChannel.APPRISE
def is_configured(self) -> bool:
return bool(settings.apprise_url_list)
async def send(self, notification: Notification, target: str | None = None) -> None:
# Eine Regel kann ein eigenes Ziel vorgeben, sonst gelten die globalen URLs.
ziele = [target] if target else settings.apprise_url_list
if not ziele:
raise ChannelError("Apprise ist nicht konfiguriert (APPRISE_URLS fehlt).")
try:
erfolg = await asyncio.to_thread(self._deliver, notification, ziele)
except Exception as exc:
raise ChannelError(f"Apprise-Versand fehlgeschlagen: {exc}") from exc
if not erfolg:
raise ChannelError("Apprise konnte die Nachricht an kein Ziel zustellen.")
def _deliver(self, notification: Notification, ziele: list[str]) -> bool:
import apprise
sammlung = apprise.Apprise()
for ziel in ziele:
if not sammlung.add(ziel):
logger.warning("Apprise-Ziel konnte nicht gelesen werden: %s", ziel)
if len(sammlung) == 0:
raise ChannelError("Kein gültiges Apprise-Ziel.")
return bool(sammlung.notify(title=notification.subject, body=notification.text))
# --- Registrierung -------------------------------------------------------------
_CHANNELS: dict[NotificationChannel, Channel] = {
NotificationChannel.SMTP: SmtpChannel(),
NotificationChannel.APPRISE: AppriseChannel(),
}
def get_channel(kind: NotificationChannel) -> Channel:
kanal = _CHANNELS.get(kind)
if kanal is None: # pragma: no cover - alle Enum-Werte sind registriert
raise ChannelError(f"Unbekannter Kanal: {kind}")
return kanal
def configured_channels() -> list[Channel]:
"""Alle Kanäle, die tatsächlich einsatzbereit sind."""
return [kanal for kanal in _CHANNELS.values() if kanal.is_configured()]