2026-05-18 15:13:06 +00:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""
|
2026-05-18 20:06:01 +00:00
|
|
|
notifier.py - Sends a single classified notification email via ns8-sendmail.
|
2026-05-18 15:13:06 +00:00
|
|
|
|
2026-05-18 20:06:01 +00:00
|
|
|
Delivery strategy:
|
|
|
|
|
1. ns8-sendmail -t (uses NS8 configured relay - preferred)
|
|
|
|
|
2. sendmail -t (fallback if ns8-sendmail not found)
|
2026-05-18 15:28:57 +00:00
|
|
|
|
2026-05-18 20:06:01 +00:00
|
|
|
No SMTP configuration needed: ns8-sendmail already knows the relay.
|
2026-05-18 15:13:06 +00:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import logging
|
2026-05-18 20:06:01 +00:00
|
|
|
import shutil
|
|
|
|
|
import subprocess
|
2026-05-18 15:13:06 +00:00
|
|
|
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__)
|
|
|
|
|
|
2026-05-18 15:28:57 +00:00
|
|
|
OUTCOME_LABEL = {
|
2026-05-18 15:13:06 +00:00
|
|
|
"SUCCESS": "OK",
|
|
|
|
|
"PARTIAL": "WARNING",
|
|
|
|
|
"REPO_FAILURE": "CRITICAL",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
OUTCOME_COLOR = {
|
|
|
|
|
"SUCCESS": "#2e7d32",
|
|
|
|
|
"PARTIAL": "#e65100",
|
|
|
|
|
"REPO_FAILURE": "#b71c1c",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-05-18 20:06:01 +00:00
|
|
|
def _sendmail_binary() -> str:
|
|
|
|
|
"""Return the path to ns8-sendmail, falling back to sendmail."""
|
|
|
|
|
for cmd in ("ns8-sendmail", "sendmail"):
|
|
|
|
|
path = shutil.which(cmd)
|
|
|
|
|
if path:
|
|
|
|
|
return path
|
|
|
|
|
raise FileNotFoundError("Neither ns8-sendmail nor sendmail found in PATH")
|
|
|
|
|
|
|
|
|
|
|
2026-05-18 15:13:06 +00:00
|
|
|
def _build_text(correlation: dict, repo_status: Optional[dict]) -> str:
|
|
|
|
|
outcome = correlation["outcome"]
|
|
|
|
|
lines = [
|
2026-05-18 15:28:57 +00:00
|
|
|
f"NS8 Backup Monitor - {OUTCOME_LABEL[outcome]}: {outcome}",
|
2026-05-18 15:13:06 +00:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-05-18 20:06:01 +00:00
|
|
|
def _build_html(correlation: dict, repo_status: Optional[dict]) -> str:
|
2026-05-18 15:13:06 +00:00
|
|
|
outcome = correlation["outcome"]
|
|
|
|
|
color = OUTCOME_COLOR[outcome]
|
2026-05-18 15:28:57 +00:00
|
|
|
label = OUTCOME_LABEL[outcome]
|
2026-05-18 15:13:06 +00:00
|
|
|
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>'
|
|
|
|
|
|
2026-05-18 20:06:01 +00:00
|
|
|
return f"""
|
2026-05-18 15:13:06 +00:00
|
|
|
<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>
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def send_notification(
|
|
|
|
|
config: dict,
|
|
|
|
|
alerts: list,
|
|
|
|
|
correlation: dict,
|
|
|
|
|
repo_status: Optional[dict] = None
|
|
|
|
|
):
|
|
|
|
|
outcome = correlation["outcome"]
|
|
|
|
|
mail_cfg = config.get("mail", {})
|
2026-05-18 15:28:57 +00:00
|
|
|
subject_prefix = mail_cfg.get("subject_prefix", "[NS8 Backup]")
|
2026-05-18 20:06:01 +00:00
|
|
|
subject = (
|
|
|
|
|
f"{subject_prefix} {OUTCOME_LABEL[outcome]}: {outcome} - "
|
|
|
|
|
f"{datetime.now(timezone.utc).strftime('%Y-%m-%d')}"
|
|
|
|
|
)
|
2026-05-18 15:13:06 +00:00
|
|
|
|
|
|
|
|
mail_to = mail_cfg.get("to", [])
|
2026-05-18 15:28:57 +00:00
|
|
|
if not mail_to:
|
2026-05-18 20:06:01 +00:00
|
|
|
log.error("No mail.to recipients configured - cannot send notification")
|
2026-05-18 15:28:57 +00:00
|
|
|
return
|
|
|
|
|
|
2026-05-18 20:06:01 +00:00
|
|
|
mail_from = mail_cfg.get("from", "")
|
2026-05-18 15:13:06 +00:00
|
|
|
|
|
|
|
|
text_body = _build_text(correlation, repo_status)
|
2026-05-18 20:06:01 +00:00
|
|
|
html_body = _build_html(correlation, repo_status)
|
2026-05-18 15:13:06 +00:00
|
|
|
|
|
|
|
|
msg = MIMEMultipart("alternative")
|
|
|
|
|
msg["Subject"] = subject
|
|
|
|
|
msg["To"] = ", ".join(mail_to)
|
2026-05-18 20:06:01 +00:00
|
|
|
if mail_from:
|
|
|
|
|
msg["From"] = mail_from
|
2026-05-18 15:13:06 +00:00
|
|
|
msg.attach(MIMEText(text_body, "plain"))
|
|
|
|
|
msg.attach(MIMEText(html_body, "html"))
|
|
|
|
|
|
2026-05-18 20:06:01 +00:00
|
|
|
try:
|
|
|
|
|
binary = _sendmail_binary()
|
|
|
|
|
except FileNotFoundError as e:
|
|
|
|
|
log.error(str(e))
|
|
|
|
|
return
|
2026-05-18 15:13:06 +00:00
|
|
|
|
|
|
|
|
try:
|
2026-05-18 20:06:01 +00:00
|
|
|
proc = subprocess.run(
|
|
|
|
|
[binary, "-t"],
|
|
|
|
|
input=msg.as_bytes(),
|
|
|
|
|
capture_output=True,
|
|
|
|
|
timeout=30,
|
|
|
|
|
)
|
|
|
|
|
if proc.returncode != 0:
|
|
|
|
|
log.error(
|
|
|
|
|
f"ns8-sendmail exited {proc.returncode}: {proc.stderr.decode().strip()}"
|
|
|
|
|
)
|
2026-05-18 15:13:06 +00:00
|
|
|
else:
|
2026-05-18 20:06:01 +00:00
|
|
|
log.info(f"Notification sent via {binary}: {subject} -> {mail_to}")
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
log.error(f"ns8-sendmail timed out after 30s")
|
2026-05-18 15:13:06 +00:00
|
|
|
except Exception as e:
|
2026-05-18 20:06:01 +00:00
|
|
|
log.error(f"Failed to invoke {binary}: {e}")
|