from __future__ import annotations import asyncio import json import logging import smtplib import ssl import types from email.message import EmailMessage import httpx from jinja2.sandbox import SandboxedEnvironment logger = logging.getLogger(__name__) async def dispatch_notifications( smtp_cfg: dict, webhook_cfg: dict, alert_data: dict, ) -> dict: """Dispatch all configured notification channels. Returns results dict.""" results = {} if smtp_cfg.get("host") and smtp_cfg.get("recipients"): results["email"] = await _send_email(smtp_cfg, alert_data) if webhook_cfg.get("url"): results["webhook"] = await _send_webhook(webhook_cfg, alert_data) return results SMTP_TIMEOUT_S = 15 def smtp_mode(port, tls) -> str: """How to talk to the SMTP server: "ssl" (implicit TLS, port 465), "starttls" (upgrade a plain connection, when TLS is ticked) or "plain".""" if int(port or 0) == 465: return "ssl" return "starttls" if tls else "plain" def email_body(alert: dict) -> str: value = "no data" if alert.get("value") is None else alert["value"] return ( f"Alert: {alert['state']}\n" f"Rule: {alert['rule_name']}\n" f"Resource: {alert['resource']}\n" f"Metric: {alert['metric']} = {value}\n" f"Threshold: {alert['threshold']}\n" + (f"Note: {alert['note']}\n" if alert.get("note") else "") + f"Time: {alert['timestamp']}\n" ) async def _send_email(cfg: dict, alert: dict) -> dict: try: msg = EmailMessage() # A header may not hold a line break; a rule name can (MCP takes any string). msg["Subject"] = " ".join(f"[Steward] {alert['state']} — {alert['rule_name']}".split()) msg["From"] = cfg.get("from") or cfg.get("username") or "steward@localhost" msg["To"] = ", ".join(cfg["recipients"]) msg.set_content(email_body(alert)) loop = asyncio.get_running_loop() await loop.run_in_executor(None, _smtp_send_sync, cfg, msg) return {"sent": True, "error": None} except Exception as exc: logger.error(f"Email notification failed: {exc}") return {"sent": False, "error": str(exc)} def _smtp_send_sync(cfg: dict, msg: EmailMessage) -> None: port = int(cfg.get("port") or 587) mode = smtp_mode(port, cfg.get("tls", True)) ctx = ssl.create_default_context() if mode == "ssl": smtp = smtplib.SMTP_SSL(cfg["host"], port, timeout=SMTP_TIMEOUT_S, context=ctx) else: smtp = smtplib.SMTP(cfg["host"], port, timeout=SMTP_TIMEOUT_S) with smtp: if mode == "starttls": smtp.starttls(context=ctx) if cfg.get("username"): smtp.login(cfg["username"], cfg.get("password", "")) smtp.send_message(msg) def _json_str(value) -> str: """A value as it may sit between the quotes of a JSON string.""" return json.dumps("" if value is None else str(value))[1:-1] def render_webhook(template_str: str, alert: dict) -> str: """The webhook body for an alert, checked to be JSON. In the template, `alert.` is escaped to sit inside a JSON string ("{{ alert.rule_name }}" stays valid whatever the name holds), with a missing value reading "no data"; `raw.` is the plain value for use with |tojson ({{ raw.value | tojson }} gives a number or null). The template runs sandboxed. ValueError says why a body could not be made. """ shown = dict(alert) if shown.get("value") is None: shown["value"] = "no data" env = SandboxedEnvironment() try: rendered = env.from_string(template_str).render( alert=types.SimpleNamespace(**{k: _json_str(v) for k, v in shown.items()}), raw=types.SimpleNamespace(**alert)) except Exception as exc: raise ValueError(f"Template render failed: {exc}") from None try: json.loads(rendered) except json.JSONDecodeError as exc: raise ValueError(f"Rendered webhook template is not valid JSON: {exc}") from None return rendered async def _send_webhook(cfg: dict, alert: dict) -> dict: template_str = cfg.get("template") or '{"content": "{{ alert.state }} — {{ alert.rule_name }}"}' try: rendered = render_webhook(template_str, alert) except ValueError as exc: logger.error(str(exc)) return {"sent": False, "error": str(exc)} try: async with httpx.AsyncClient(timeout=10) as client: response = await client.post( cfg["url"], content=rendered, headers={"Content-Type": "application/json"}, ) response.raise_for_status() return {"sent": True, "error": None} except Exception as exc: # The URL is a secret (it usually carries its own token) and httpx # puts it in its error messages. error = str(exc).replace(cfg["url"], "") logger.error(f"Webhook notification failed: {error}") return {"sent": False, "error": error}