135 lines
6.6 KiB
Python
135 lines
6.6 KiB
Python
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
|