feat: add unified email notifier with classified outcome
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
notifier.py - Sends a single classified notification email.
|
||||
|
||||
Formats a human-readable HTML + text email based on:
|
||||
- correlation outcome (SUCCESS / PARTIAL / REPO_FAILURE)
|
||||
- per-module statuses
|
||||
- repository check results (if run)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
from datetime import datetime, timezone
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Optional
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
OUTCOME_EMOJI = {
|
||||
"SUCCESS": "OK",
|
||||
"PARTIAL": "WARNING",
|
||||
"REPO_FAILURE": "CRITICAL",
|
||||
}
|
||||
|
||||
OUTCOME_COLOR = {
|
||||
"SUCCESS": "#2e7d32",
|
||||
"PARTIAL": "#e65100",
|
||||
"REPO_FAILURE": "#b71c1c",
|
||||
}
|
||||
|
||||
|
||||
def _build_text(correlation: dict, repo_status: Optional[dict]) -> str:
|
||||
outcome = correlation["outcome"]
|
||||
lines = [
|
||||
f"NS8 Backup Monitor - {OUTCOME_EMOJI[outcome]}: {outcome}",
|
||||
f"Time: {datetime.now(timezone.utc).isoformat()}",
|
||||
f"Plans checked: {', '.join(correlation.get('backup_ids', []))}",
|
||||
f"Modules: {correlation['succeeded']} OK / {correlation['failed']} FAILED / {correlation['total']} total",
|
||||
"",
|
||||
]
|
||||
|
||||
if correlation["failed_modules"]:
|
||||
lines.append("Failed modules:")
|
||||
for m in correlation["failed_modules"]:
|
||||
lines.append(f" - [{m['module_id']}] backup_id={m['backup_id']}: {m.get('error', 'unknown error')}")
|
||||
lines.append("")
|
||||
|
||||
if repo_status:
|
||||
lines.append("Repository check:")
|
||||
for dest in repo_status.get("destinations", []):
|
||||
lines.append(f" - [{dest['repo_id']}] {dest['status']}: {dest.get('error', '')}")
|
||||
if repo_status.get("note"):
|
||||
lines.append(f" NOTE: {repo_status['note']}")
|
||||
lines.append("")
|
||||
|
||||
if correlation.get("note"):
|
||||
lines.append(f"Note: {correlation['note']}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_html(correlation: dict, repo_status: Optional[dict]) -> str:
|
||||
outcome = correlation["outcome"]
|
||||
color = OUTCOME_COLOR[outcome]
|
||||
label = OUTCOME_EMOJI[outcome]
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
plan_ids = ", ".join(correlation.get("backup_ids", [])) or "N/A"
|
||||
|
||||
rows = ""
|
||||
for m in correlation.get("modules", []):
|
||||
bg = "#e8f5e9" if m["result"] == "success" else "#ffebee"
|
||||
icon = "✓" if m["result"] == "success" else "✗"
|
||||
rows += (
|
||||
f'<tr style="background:{bg}">'
|
||||
f'<td style="padding:4px 8px">{icon}</td>'
|
||||
f'<td style="padding:4px 8px">{m["module_id"]}</td>'
|
||||
f'<td style="padding:4px 8px">{m["backup_id"]}</td>'
|
||||
f'<td style="padding:4px 8px">{m.get("timestamp", "")}</td>'
|
||||
f'<td style="padding:4px 8px">{m.get("error", "") or ""}</td>'
|
||||
'</tr>'
|
||||
)
|
||||
|
||||
repo_section = ""
|
||||
if repo_status:
|
||||
repo_rows = ""
|
||||
for dest in repo_status.get("destinations", []):
|
||||
bg = "#e8f5e9" if dest["status"] == "OK" else "#ffebee"
|
||||
repo_rows += (
|
||||
f'<tr style="background:{bg}">'
|
||||
f'<td style="padding:4px 8px">{dest["repo_id"]}</td>'
|
||||
f'<td style="padding:4px 8px"><b>{dest["status"]}</b></td>'
|
||||
f'<td style="padding:4px 8px">{dest.get("error", "")}</td>'
|
||||
'</tr>'
|
||||
)
|
||||
if repo_rows:
|
||||
repo_section = f"""
|
||||
<h3 style="margin-top:24px">Repository check</h3>
|
||||
<table border="1" cellspacing="0" cellpadding="0" style="border-collapse:collapse;font-size:13px;width:100%">
|
||||
<thead><tr style="background:#f5f5f5">
|
||||
<th style="padding:4px 8px">Repo ID</th>
|
||||
<th style="padding:4px 8px">Status</th>
|
||||
<th style="padding:4px 8px">Detail</th>
|
||||
</tr></thead>
|
||||
<tbody>{repo_rows}</tbody>
|
||||
</table>
|
||||
"""
|
||||
if repo_status.get("note"):
|
||||
repo_section += f'<p style="color:#777;font-size:12px">{repo_status["note"]}</p>'
|
||||
|
||||
html = f"""
|
||||
<html><body style="font-family:monospace;font-size:14px;max-width:800px;margin:auto">
|
||||
<div style="background:{color};color:#fff;padding:16px 20px;border-radius:6px 6px 0 0">
|
||||
<b>NS8 Backup Monitor — {label}: {outcome}</b><br>
|
||||
<small>{ts} • Plans: {plan_ids}</small>
|
||||
</div>
|
||||
<div style="border:1px solid #ddd;border-top:none;padding:16px 20px;border-radius:0 0 6px 6px">
|
||||
<p>
|
||||
<b>{correlation['succeeded']}</b> module(s) OK |
|
||||
<b>{correlation['failed']}</b> FAILED |
|
||||
<b>{correlation['total']}</b> total
|
||||
</p>
|
||||
<table border="1" cellspacing="0" cellpadding="0" style="border-collapse:collapse;font-size:13px;width:100%">
|
||||
<thead><tr style="background:#f5f5f5">
|
||||
<th style="padding:4px 8px"></th>
|
||||
<th style="padding:4px 8px">Module</th>
|
||||
<th style="padding:4px 8px">Backup ID</th>
|
||||
<th style="padding:4px 8px">Timestamp</th>
|
||||
<th style="padding:4px 8px">Error</th>
|
||||
</tr></thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
{repo_section}
|
||||
</div>
|
||||
</body></html>
|
||||
"""
|
||||
return html
|
||||
|
||||
|
||||
def send_notification(
|
||||
config: dict,
|
||||
alerts: list,
|
||||
correlation: dict,
|
||||
repo_status: Optional[dict] = None
|
||||
):
|
||||
outcome = correlation["outcome"]
|
||||
mail_cfg = config.get("mail", {})
|
||||
smtp_cfg = config.get("smtp", {})
|
||||
|
||||
mail_from = mail_cfg.get("from", "ns8-backup-monitor@localhost")
|
||||
mail_to = mail_cfg.get("to", [])
|
||||
subject_prefix = mail_cfg.get("subject_prefix", "[NS8 Backup]")
|
||||
subject = f"{subject_prefix} {OUTCOME_EMOJI[outcome]}: {outcome} - {datetime.now(timezone.utc).strftime('%Y-%m-%d')}"
|
||||
|
||||
text_body = _build_text(correlation, repo_status)
|
||||
html_body = _build_html(correlation, repo_status)
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = mail_from
|
||||
msg["To"] = ", ".join(mail_to)
|
||||
msg.attach(MIMEText(text_body, "plain"))
|
||||
msg.attach(MIMEText(html_body, "html"))
|
||||
|
||||
host = smtp_cfg.get("host", "localhost")
|
||||
port = smtp_cfg.get("port", 25)
|
||||
use_tls = smtp_cfg.get("use_tls", False)
|
||||
use_starttls = smtp_cfg.get("use_starttls", False)
|
||||
username = smtp_cfg.get("username", "")
|
||||
password = smtp_cfg.get("password", "")
|
||||
|
||||
try:
|
||||
if use_tls:
|
||||
smtp = smtplib.SMTP_SSL(host, port)
|
||||
else:
|
||||
smtp = smtplib.SMTP(host, port)
|
||||
if use_starttls:
|
||||
smtp.starttls()
|
||||
if username and password:
|
||||
smtp.login(username, password)
|
||||
smtp.sendmail(mail_from, mail_to, msg.as_string())
|
||||
smtp.quit()
|
||||
log.info(f"Notification sent: {subject} -> {mail_to}")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to send notification: {e}")
|
||||
Reference in New Issue
Block a user