"""Email and webhook notification delivery for alert events. Best-effort delivery: failures are logged but never raised. Each dispatch is wrapped in try/except so one failing channel doesn't prevent delivery to other channels. """ import logging from typing import Any import httpx logger = logging.getLogger(__name__) async def dispatch_notifications( alert_event: dict[str, Any], channels: list[dict[str, Any]], device_hostname: str, ) -> None: """Send notifications for an alert event to all provided channels. Args: alert_event: Dict with alert event fields (status, severity, metric, etc.) channels: List of notification channel dicts device_hostname: Human-readable device name for messages """ for channel in channels: try: if channel["channel_type"] == "email": await _send_email(channel, alert_event, device_hostname) elif channel["channel_type"] == "webhook": await _send_webhook(channel, alert_event, device_hostname) elif channel["channel_type"] == "slack": await _send_slack(channel, alert_event, device_hostname) else: logger.warning("Unknown channel type: %s", channel["channel_type"]) except Exception as e: logger.warning( "Notification delivery failed for channel %s (%s): %s", channel.get("name"), channel.get("channel_type"), e, ) async def _send_email(channel: dict, alert_event: dict, device_hostname: str) -> None: """Send alert notification email using per-channel SMTP config.""" from app.services.email_service import SMTPConfig, send_email severity = alert_event.get("severity", "warning") status = alert_event.get("status", "firing") rule_name = alert_event.get("rule_name") or alert_event.get("message", "Unknown Rule") metric = alert_event.get("metric_name") or alert_event.get("metric", "") value = alert_event.get("current_value") or alert_event.get("value", "") threshold = alert_event.get("threshold", "") severity_colors = { "critical": "#ef4444", "warning": "#f59e0b", "info": "#38bdf8", } color = severity_colors.get(severity, "#38bdf8") status_label = "RESOLVED" if status == "resolved" else "FIRING" html = f"""

[{status_label}] {rule_name}

Device{device_hostname}
Severity{severity.upper()}
Metric{metric}
Value{value}
Threshold{threshold}

TOD — Fleet Management for MikroTik RouterOS

""" plain = ( f"[{status_label}] {rule_name}\n\n" f"Device: {device_hostname}\n" f"Severity: {severity}\n" f"Metric: {metric}\n" f"Value: {value}\n" f"Threshold: {threshold}\n" ) # Decrypt SMTP password (Transit first, then legacy Fernet) smtp_password = None transit_cipher = channel.get("smtp_password_transit") legacy_cipher = channel.get("smtp_password") tenant_id = channel.get("tenant_id") if transit_cipher and tenant_id: try: from app.services.kms_service import decrypt_transit smtp_password = await decrypt_transit(transit_cipher, tenant_id) except Exception: logger.warning("Transit decryption failed for channel %s, trying legacy", channel.get("id")) if not smtp_password and legacy_cipher: try: from app.config import settings as app_settings from cryptography.fernet import Fernet raw = bytes(legacy_cipher) if isinstance(legacy_cipher, memoryview) else legacy_cipher f = Fernet(app_settings.CREDENTIAL_ENCRYPTION_KEY.encode()) smtp_password = f.decrypt(raw).decode() except Exception: logger.warning("Legacy decryption failed for channel %s", channel.get("id")) config = SMTPConfig( host=channel.get("smtp_host", "localhost"), port=channel.get("smtp_port", 587), user=channel.get("smtp_user"), password=smtp_password, use_tls=channel.get("smtp_use_tls", False), from_address=channel.get("from_address") or "alerts@the-other-dude.local", ) to = channel.get("to_address") subject = f"[TOD {status_label}] {rule_name} — {device_hostname}" await send_email(to, subject, html, plain, config) async def _send_webhook( channel: dict[str, Any], alert_event: dict[str, Any], device_hostname: str, ) -> None: """Send alert notification to a webhook URL (Slack-compatible JSON).""" severity = alert_event.get("severity", "info") status = alert_event.get("status", "firing") metric = alert_event.get("metric") value = alert_event.get("value") threshold = alert_event.get("threshold") message_text = alert_event.get("message", "") payload = { "alert_name": message_text, "severity": severity, "status": status, "device": device_hostname, "device_id": alert_event.get("device_id"), "metric": metric, "value": value, "threshold": threshold, "timestamp": str(alert_event.get("fired_at", "")), "text": f"[{severity.upper()}] {device_hostname}: {message_text}", } webhook_url = channel.get("webhook_url", "") if not webhook_url: logger.warning("Webhook channel %s has no URL configured", channel.get("name")) return async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post(webhook_url, json=payload) logger.info( "Webhook notification sent to %s — status %d", webhook_url, response.status_code, ) async def _send_slack( channel: dict[str, Any], alert_event: dict[str, Any], device_hostname: str, ) -> None: """Send alert notification to Slack via incoming webhook with Block Kit formatting.""" severity = alert_event.get("severity", "info").upper() status = alert_event.get("status", "firing") metric = alert_event.get("metric", "unknown") message_text = alert_event.get("message", "") value = alert_event.get("value") threshold = alert_event.get("threshold") color = {"CRITICAL": "#dc2626", "WARNING": "#f59e0b", "INFO": "#3b82f6"}.get(severity, "#6b7280") status_label = "RESOLVED" if status == "resolved" else status blocks = [ { "type": "header", "text": {"type": "plain_text", "text": f"{'✅' if status == 'resolved' else '🚨'} [{severity}] {status_label.upper()}"}, }, { "type": "section", "fields": [ {"type": "mrkdwn", "text": f"*Device:*\n{device_hostname}"}, {"type": "mrkdwn", "text": f"*Metric:*\n{metric}"}, ], }, ] if value is not None or threshold is not None: fields = [] if value is not None: fields.append({"type": "mrkdwn", "text": f"*Value:*\n{value}"}) if threshold is not None: fields.append({"type": "mrkdwn", "text": f"*Threshold:*\n{threshold}"}) blocks.append({"type": "section", "fields": fields}) if message_text: blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": f"*Message:*\n{message_text}"}}) blocks.append({"type": "context", "elements": [{"type": "mrkdwn", "text": "TOD Alert System"}]}) slack_url = channel.get("slack_webhook_url", "") if not slack_url: logger.warning("Slack channel %s has no webhook URL configured", channel.get("name")) return payload = {"attachments": [{"color": color, "blocks": blocks}]} async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post(slack_url, json=payload) logger.info("Slack notification sent — status %d", response.status_code) async def send_test_notification(channel: dict[str, Any]) -> bool: """Send a test notification through a channel to verify configuration. Args: channel: Notification channel dict with all config fields Returns: True on success Raises: Exception on delivery failure (caller handles) """ test_event = { "status": "test", "severity": "info", "metric": "test", "value": None, "threshold": None, "message": "Test notification from TOD", "device_id": "00000000-0000-0000-0000-000000000000", "fired_at": "", } if channel["channel_type"] == "email": await _send_email(channel, test_event, "Test Device") elif channel["channel_type"] == "webhook": await _send_webhook(channel, test_event, "Test Device") elif channel["channel_type"] == "slack": await _send_slack(channel, test_event, "Test Device") else: raise ValueError(f"Unknown channel type: {channel['channel_type']}") return True