- 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
197 lines
6.6 KiB
Python
197 lines
6.6 KiB
Python
"""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()]
|