from __future__ import annotations from email.message import EmailMessage import json import smtplib import ssl import time from typing import Any from urllib import error, request from ...core.redaction import redact_message from ...db.models import NotificationChannel from ..secret_store import get_secret SECRET_REFS = { "discord": "NOTIFICATION_DISCORD_WEBHOOK", "telegram": "NOTIFICATION_TELEGRAM_BOT_TOKEN", "email": "NOTIFICATION_SMTP_PASSWORD", } def safe_config(channel_type: str, config: dict[str, Any]) -> dict[str, Any]: allowed = { "discord": {"username", "timeoutSeconds", "maxAttempts", "retryBackoffSeconds"}, "telegram": {"chatId", "timeoutSeconds", "maxAttempts", "retryBackoffSeconds"}, "email": {"host", "port", "username", "fromAddress", "toAddress", "useTls", "smtpMode", "timeoutSeconds", "maxAttempts", "retryBackoffSeconds"}, }.get(channel_type, set()) return {key: value for key, value in config.items() if key in allowed} def config_status(session, channel: NotificationChannel) -> dict[str, Any]: config = safe_config(channel.type, channel.config_json or {}) secret_ref = SECRET_REFS.get(channel.type) has_secret = bool(secret_ref and get_secret(session, secret_ref, touch=False)) missing: list[str] = [] if channel.type == "telegram" and not str(config.get("chatId") or "").strip(): missing.append("chatId") if channel.type == "email": for field in ("host", "fromAddress", "toAddress"): if not str(config.get(field) or "").strip(): missing.append(field) if not has_secret: missing.append("credential") elif channel.type == "discord": webhook = get_secret(session, secret_ref, touch=False) or "" if not webhook.startswith(("https://discord.com/api/webhooks/", "https://discordapp.com/api/webhooks/")): missing.append("validDiscordWebhook") return { "status": "disabled" if not channel.enabled else ("configured" if not missing else "missing_config"), "hasSecret": has_secret, "missing": missing, "safeConfig": config, } def _http_json(url: str, payload: dict[str, Any], timeout: int) -> dict[str, Any]: try: body = json.dumps(payload).encode() req = request.Request(url, data=body, headers={"Content-Type": "application/json"}, method="POST") with request.urlopen(req, timeout=timeout) as response: status = int(response.status) return {"success": 200 <= status < 300, "status": "sent" if 200 <= status < 300 else "failed", "httpStatus": status} except error.HTTPError as exc: return {"success": False, "status": "failed", "httpStatus": int(exc.code), "reason": "remote_http_error"} except (error.URLError, TimeoutError, OSError, ValueError): return {"success": False, "status": "failed", "reason": "connection_failed"} def _send_once(session, channel: NotificationChannel, event: dict[str, Any]) -> dict[str, Any]: status = config_status(session, channel) if not channel.enabled: return {"success": True, "status": "disabled"} if status["missing"]: return {"success": False, "status": "missing_config", "missing": status["missing"]} config = status["safeConfig"] timeout = max(2, min(int(config.get("timeoutSeconds") or 10), 30)) title = redact_message(str(event.get("title") or "AppOps notification"))[:200] message = redact_message(str(event.get("message") or ""))[:1500] secret = get_secret(session, SECRET_REFS[channel.type], touch=True) or "" if channel.type == "discord": return _http_json(secret, {"username": str(config.get("username") or "AppOps"), "content": f"**{title}**\n{message}"}, timeout) if channel.type == "telegram": url = f"https://api.telegram.org/bot{secret}/sendMessage" return _http_json(url, {"chat_id": str(config["chatId"]), "text": f"{title}\n{message}"}, timeout) if channel.type == "email": email = EmailMessage() email["Subject"] = title email["From"] = str(config["fromAddress"]) email["To"] = str(config["toAddress"]) email.set_content(message) try: smtp_mode = str(config.get("smtpMode") or ("implicit_tls" if config.get("useTls", True) else "plain")) if smtp_mode not in {"implicit_tls", "starttls", "plain"}: return {"success": False, "status": "missing_config", "missing": ["validSmtpMode"]} port = int(config.get("port") or (465 if smtp_mode == "implicit_tls" else 587 if smtp_mode == "starttls" else 25)) if smtp_mode == "implicit_tls": with smtplib.SMTP_SSL(str(config["host"]), port, timeout=timeout, context=ssl.create_default_context()) as smtp: if config.get("username"): smtp.login(str(config["username"]), secret) smtp.send_message(email) else: with smtplib.SMTP(str(config["host"]), port, timeout=timeout) as smtp: if smtp_mode == "starttls": smtp.ehlo() smtp.starttls(context=ssl.create_default_context()) smtp.ehlo() if config.get("username"): smtp.login(str(config["username"]), secret) smtp.send_message(email) return {"success": True, "status": "sent"} except (OSError, smtplib.SMTPException, ValueError): return {"success": False, "status": "failed", "reason": "smtp_delivery_failed"} return {"success": False, "status": "unsupported_channel"} def send_notification(session, channel: NotificationChannel, event: dict[str, Any]) -> dict[str, Any]: config = safe_config(channel.type, channel.config_json or {}) max_attempts = max(1, min(int(config.get("maxAttempts") or 3), 3)) backoff = max(0.0, min(float(config.get("retryBackoffSeconds") or 0.25), 2.0)) result: dict[str, Any] = {"success": False, "status": "failed", "reason": "delivery_failed"} for attempt in range(1, max_attempts + 1): result = _send_once(session, channel, event) result["attempts"] = attempt if result.get("success") or result.get("status") in {"disabled", "missing_config", "unsupported_channel"}: return result retryable = result.get("reason") in {"connection_failed", "smtp_delivery_failed"} or int(result.get("httpStatus") or 0) >= 500 or int(result.get("httpStatus") or 0) == 429 if not retryable: return result if attempt < max_attempts and backoff: time.sleep(backoff * (2 ** (attempt - 1))) result["deadLetter"] = True return result