Adding the new rewrite
This commit is contained in:
1 parent
4aca9f08d1
commit
9b48b01d1d
62 files changed
+4510
-1406
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Notification channels."""
|
||||
@@ -0,0 +1,157 @@
|
||||
"""SMTP email sending with retry, batching, and HTML templates."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
from datetime import datetime
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from tenacity import (
|
||||
RetryError,
|
||||
retry,
|
||||
retry_if_exception_type,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from ..config import SMTPConfig
|
||||
from ..settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TEMPLATE_DIR = Path(__file__).parent.parent / "templates" / "emails"
|
||||
_env = Environment(
|
||||
loader=FileSystemLoader(str(_TEMPLATE_DIR)),
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
)
|
||||
|
||||
SEVERITY_COLORS = {
|
||||
"CRITICAL": "#c0392b",
|
||||
"WARNING": "#e67e22",
|
||||
"INFO": "#2980b9",
|
||||
}
|
||||
|
||||
|
||||
class EmailSendError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_password(smtp_cfg: SMTPConfig) -> str | None:
|
||||
# Password is never stored in Redis; always from env var
|
||||
return settings.smtp_password
|
||||
|
||||
|
||||
@retry(
|
||||
reraise=True,
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=1, min=1, max=10),
|
||||
retry=retry_if_exception_type((smtplib.SMTPException, OSError)),
|
||||
)
|
||||
def _send_raw(smtp_cfg: SMTPConfig, msg: EmailMessage) -> None:
|
||||
password = _resolve_password(smtp_cfg)
|
||||
if smtp_cfg.use_ssl:
|
||||
context = ssl.create_default_context()
|
||||
with smtplib.SMTP_SSL(
|
||||
smtp_cfg.host, smtp_cfg.port, context=context, timeout=30
|
||||
) as server:
|
||||
if smtp_cfg.username and password:
|
||||
server.login(smtp_cfg.username, password)
|
||||
server.send_message(msg)
|
||||
else:
|
||||
with smtplib.SMTP(smtp_cfg.host, smtp_cfg.port, timeout=30) as server:
|
||||
if smtp_cfg.use_tls:
|
||||
server.starttls(context=ssl.create_default_context())
|
||||
if smtp_cfg.username and password:
|
||||
server.login(smtp_cfg.username, password)
|
||||
server.send_message(msg)
|
||||
|
||||
|
||||
def send_alert_email(
|
||||
smtp_cfg: SMTPConfig,
|
||||
ups_name: str,
|
||||
alerts: list[dict[str, Any]],
|
||||
dashboard_url: str = "http://localhost:8000/",
|
||||
) -> None:
|
||||
"""Send a single HTML email coalescing multiple alerts for a UPS."""
|
||||
if not smtp_cfg.to_addrs or not alerts:
|
||||
return
|
||||
top_severity = _pick_top_severity(alerts)
|
||||
subject = f"{smtp_cfg.subject_prefix} [{top_severity}] {ups_name}"
|
||||
template = _env.get_template("alert.html")
|
||||
html = template.render(
|
||||
ups_name=ups_name,
|
||||
alerts=alerts,
|
||||
top_severity=top_severity,
|
||||
color=SEVERITY_COLORS.get(top_severity, "#555"),
|
||||
dashboard_url=dashboard_url,
|
||||
sent_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
text_body = "\n".join(
|
||||
f"[{a.get('severity', 'INFO')}] {a.get('message', '')}" for a in alerts
|
||||
)
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = smtp_cfg.from_addr or (smtp_cfg.username or "ups@example.local")
|
||||
msg["To"] = ", ".join(smtp_cfg.to_addrs)
|
||||
msg.set_content(text_body)
|
||||
msg.add_alternative(html, subtype="html")
|
||||
try:
|
||||
_send_raw(smtp_cfg, msg)
|
||||
except RetryError as e:
|
||||
raise EmailSendError(f"SMTP send failed after retries: {e}") from e
|
||||
except Exception as e: # noqa: BLE001 - surface as domain error
|
||||
raise EmailSendError(f"SMTP send failed: {e}") from e
|
||||
|
||||
|
||||
def send_test_email(smtp_cfg: SMTPConfig) -> None:
|
||||
if not smtp_cfg.to_addrs:
|
||||
raise EmailSendError("No recipients configured")
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = f"{smtp_cfg.subject_prefix} Test email"
|
||||
msg["From"] = smtp_cfg.from_addr or (smtp_cfg.username or "ups@example.local")
|
||||
msg["To"] = ", ".join(smtp_cfg.to_addrs)
|
||||
msg.set_content(
|
||||
"This is a test message from your APC UPS Dashboard.\n"
|
||||
f"Sent at {datetime.now().isoformat()}"
|
||||
)
|
||||
try:
|
||||
_send_raw(smtp_cfg, msg)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise EmailSendError(str(e)) from e
|
||||
|
||||
|
||||
def send_daily_summary(
|
||||
smtp_cfg: SMTPConfig,
|
||||
summary: dict[str, Any],
|
||||
) -> None:
|
||||
if not smtp_cfg.to_addrs:
|
||||
return
|
||||
template = _env.get_template("summary.html")
|
||||
html = template.render(summary=summary, sent_at=datetime.now().isoformat())
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = f"{smtp_cfg.subject_prefix} Daily summary"
|
||||
msg["From"] = smtp_cfg.from_addr or (smtp_cfg.username or "ups@example.local")
|
||||
msg["To"] = ", ".join(smtp_cfg.to_addrs)
|
||||
msg.set_content(str(summary))
|
||||
msg.add_alternative(html, subtype="html")
|
||||
try:
|
||||
_send_raw(smtp_cfg, msg)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("Daily summary send failed: %s", e)
|
||||
|
||||
|
||||
def _pick_top_severity(alerts: list[dict[str, Any]]) -> str:
|
||||
order = {"CRITICAL": 3, "WARNING": 2, "INFO": 1}
|
||||
best = "INFO"
|
||||
best_rank = 0
|
||||
for a in alerts:
|
||||
sev = a.get("severity", "INFO")
|
||||
rank = order.get(sev, 0)
|
||||
if rank > best_rank:
|
||||
best = sev
|
||||
best_rank = rank
|
||||
return best
|
||||
Reference in new issue
Block a user