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:
@@ -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()]
|
||||
Reference in New Issue
Block a user