"""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:` 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()]