From 7fe686d3667dd56bae9d92694e9c5eb40615d615 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 17 Mar 2026 19:50:25 -0400 Subject: [PATCH] feat: email and webhook notification dispatch --- fablednetmon/core/notifications.py | 100 +++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 fablednetmon/core/notifications.py diff --git a/fablednetmon/core/notifications.py b/fablednetmon/core/notifications.py new file mode 100644 index 0000000..a38ad82 --- /dev/null +++ b/fablednetmon/core/notifications.py @@ -0,0 +1,100 @@ +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 import Template + +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 + + +async def _send_email(cfg: dict, alert: dict) -> dict: + try: + msg = EmailMessage() + msg["Subject"] = f"[FabledNetMon] {alert['state']} — {alert['rule_name']}" + msg["From"] = cfg.get("username", "fablednetmon@localhost") + msg["To"] = ", ".join(cfg["recipients"]) + msg.set_content( + f"Alert: {alert['state']}\n" + f"Rule: {alert['rule_name']}\n" + f"Resource: {alert['resource']}\n" + f"Metric: {alert['metric']} = {alert['value']}\n" + f"Threshold: {alert['threshold']}\n" + f"Time: {alert['timestamp']}\n" + ) + 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: + ctx = ssl.create_default_context() if cfg.get("tls", True) else None + with smtplib.SMTP(cfg["host"], cfg.get("port", 587)) as smtp: + if ctx: + smtp.starttls(context=ctx) + if cfg.get("username"): + smtp.login(cfg["username"], cfg.get("password", "")) + smtp.send_message(msg) + + +async def _send_webhook(cfg: dict, alert: dict) -> dict: + template_str = cfg.get( + "template", + '{"content": "{{ alert.state }} — {{ alert.rule_name }}"}', + ) + + # Render Jinja2 template with alert as a namespace object (attribute access) + try: + alert_ns = types.SimpleNamespace(**alert) + rendered = Template(template_str).render(alert=alert_ns) + except Exception as exc: + error = f"Template render failed: {exc}" + logger.error(error) + return {"sent": False, "error": error} + + # Validate JSON before sending + try: + json.loads(rendered) + except json.JSONDecodeError as exc: + error = f"Rendered webhook template is not valid JSON: {exc}" + logger.error(error) + return {"sent": False, "error": error} + + 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: + error = str(exc) + logger.error(f"Webhook notification failed: {error}") + return {"sent": False, "error": error} diff --git a/pyproject.toml b/pyproject.toml index 3cfe298..2d62828 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,13 +16,13 @@ dependencies = [ "pyyaml>=6.0", "packaging>=24.0", "click>=8.0", + "httpx>=0.27", ] [project.optional-dependencies] dev = [ "pytest>=8.0", "pytest-asyncio>=0.23", - "httpx>=0.27", ] [project.scripts]