from __future__ import annotations from typing import Iterable, Optional import asyncio import logging import smtplib from email.message import EmailMessage import httpx from ..db import log_notification from ..runtime import get_runtime_settings logger = logging.getLogger(__name__) def _normalize_channels(channels: Optional[Iterable[str]]) -> list[str]: if not channels: return [] return [str(channel).strip().lower() for channel in channels if str(channel).strip()] def _send_email_sync( smtp_host: str, smtp_port: int, smtp_user: Optional[str], smtp_password: Optional[str], smtp_from: str, to_address: str, subject: str, body: str, use_tls: bool, use_starttls: bool, ) -> None: message = EmailMessage() message["From"] = smtp_from message["To"] = to_address message["Subject"] = subject message.set_content(body) if use_tls: with smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=10) as server: if smtp_user and smtp_password: server.login(smtp_user, smtp_password) server.send_message(message) else: with smtplib.SMTP(smtp_host, smtp_port, timeout=10) as server: if use_starttls: server.starttls() if smtp_user and smtp_password: server.login(smtp_user, smtp_password) server.send_message(message) async def _send_email(to_address: str, subject: str, body: str) -> None: runtime = get_runtime_settings() if not runtime.notify_email_enabled: raise RuntimeError("Email notifications disabled") if not runtime.smtp_host or not runtime.smtp_from: raise RuntimeError("SMTP not configured") await asyncio.to_thread( _send_email_sync, runtime.smtp_host, int(runtime.smtp_port or 587), runtime.smtp_user, runtime.smtp_password, runtime.smtp_from, to_address, subject, body, bool(runtime.smtp_tls), bool(runtime.smtp_starttls), ) async def _send_discord(subject: str, body: str) -> None: runtime = get_runtime_settings() if not runtime.notify_discord_enabled: raise RuntimeError("Discord notifications disabled") if not runtime.discord_webhook_url: raise RuntimeError("Discord webhook not configured") payload = {"content": f"**{subject}**\n{body}"} async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post(runtime.discord_webhook_url, json=payload) response.raise_for_status() async def _send_telegram(subject: str, body: str, chat_id: Optional[str]) -> None: runtime = get_runtime_settings() if not runtime.notify_telegram_enabled: raise RuntimeError("Telegram notifications disabled") if not runtime.telegram_bot_token: raise RuntimeError("Telegram bot token not configured") target_chat = chat_id or runtime.telegram_chat_id if not target_chat: raise RuntimeError("Telegram chat ID not configured") url = f"https://api.telegram.org/bot{runtime.telegram_bot_token}/sendMessage" payload = {"chat_id": target_chat, "text": f"{subject}\n{body}"} async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post(url, json=payload) response.raise_for_status() async def _send_matrix(subject: str, body: str) -> None: runtime = get_runtime_settings() if not runtime.notify_matrix_enabled: raise RuntimeError("Matrix notifications disabled") if not runtime.matrix_homeserver or not runtime.matrix_access_token or not runtime.matrix_room_id: raise RuntimeError("Matrix not configured") url = ( f"{runtime.matrix_homeserver}/_matrix/client/v3/rooms/" f"{runtime.matrix_room_id}/send/m.room.message" ) payload = {"msgtype": "m.text", "body": f"{subject}\n{body}"} async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post(url, json=payload, params={"access_token": runtime.matrix_access_token}) response.raise_for_status() async def _send_pushover(subject: str, body: str) -> None: runtime = get_runtime_settings() if not runtime.notify_pushover_enabled: raise RuntimeError("Pushover notifications disabled") if not runtime.pushover_token or not runtime.pushover_user_key: raise RuntimeError("Pushover not configured") payload = { "token": runtime.pushover_token, "user": runtime.pushover_user_key, "title": subject, "message": body, } async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post("https://api.pushover.net/1/messages.json", data=payload) response.raise_for_status() async def _send_pushbullet(subject: str, body: str) -> None: runtime = get_runtime_settings() if not runtime.notify_pushbullet_enabled: raise RuntimeError("Pushbullet notifications disabled") if not runtime.pushbullet_token: raise RuntimeError("Pushbullet not configured") payload = {"type": "note", "title": subject, "body": body} headers = {"Access-Token": runtime.pushbullet_token} async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post("https://api.pushbullet.com/v2/pushes", json=payload, headers=headers) response.raise_for_status() async def _send_gotify(subject: str, body: str) -> None: runtime = get_runtime_settings() if not runtime.notify_gotify_enabled: raise RuntimeError("Gotify notifications disabled") if not runtime.gotify_url or not runtime.gotify_token: raise RuntimeError("Gotify not configured") payload = {"title": subject, "message": body, "priority": 5} url = f"{runtime.gotify_url.rstrip('/')}/message" async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post(url, params={"token": runtime.gotify_token}, json=payload) response.raise_for_status() async def _send_ntfy(subject: str, body: str) -> None: runtime = get_runtime_settings() if not runtime.notify_ntfy_enabled: raise RuntimeError("ntfy notifications disabled") if not runtime.ntfy_url or not runtime.ntfy_topic: raise RuntimeError("ntfy not configured") url = f"{runtime.ntfy_url.rstrip('/')}/{runtime.ntfy_topic}" headers = {"Title": subject} async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post(url, content=body.encode("utf-8"), headers=headers) response.raise_for_status() async def send_notification( subject: str, body: str, channels: Optional[Iterable[str]] = None, email: Optional[str] = None, telegram_chat_id: Optional[str] = None, ) -> dict[str, str]: requested = _normalize_channels(channels) results: dict[str, str] = {} if not requested: return results for channel in requested: try: if channel == "email": if not email: raise RuntimeError("Email address not provided") await _send_email(email, subject, body) elif channel == "discord": await _send_discord(subject, body) elif channel == "telegram": await _send_telegram(subject, body, telegram_chat_id) elif channel == "matrix": await _send_matrix(subject, body) elif channel == "pushover": await _send_pushover(subject, body) elif channel == "pushbullet": await _send_pushbullet(subject, body) elif channel == "gotify": await _send_gotify(subject, body) elif channel == "ntfy": await _send_ntfy(subject, body) else: results[channel] = "unsupported" continue results[channel] = "sent" log_notification(channel, email or telegram_chat_id, "sent", None) except Exception as exc: # noqa: BLE001 logger.warning("Notification failed: channel=%s error=%s", channel, exc) results[channel] = "failed" log_notification(channel, email or telegram_chat_id, "failed", str(exc)) return results