Files
moneyfyandClaude Opus 5 54c59c9f71 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
2026-09-09 17:04:30 +02:00

197 lines
6.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()]