#!/usr/bin/env python3 """ notifier.py - Sends a single classified notification email via ns8-sendmail. Delivery strategy: 1. ns8-sendmail -t (uses NS8 configured relay - preferred) 2. sendmail -t (fallback if ns8-sendmail not found) No SMTP configuration needed: ns8-sendmail already knows the relay. """ import logging import shutil import subprocess 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_LABEL = { "SUCCESS": "OK", "PARTIAL": "WARNING", "REPO_FAILURE": "CRITICAL", } OUTCOME_COLOR = { "SUCCESS": "#2e7d32", "PARTIAL": "#e65100", "REPO_FAILURE": "#b71c1c", } 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") def _build_text(correlation: dict, repo_status: Optional[dict]) -> str: outcome = correlation["outcome"] lines = [ f"NS8 Backup Monitor - {OUTCOME_LABEL[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_LABEL[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'' f'{icon}' f'{m["module_id"]}' f'{m["backup_id"]}' f'{m.get("timestamp", "")}' f'{m.get("error", "") or ""}' '' ) 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'' f'{dest["repo_id"]}' f'{dest["status"]}' f'{dest.get("error", "")}' '' ) if repo_rows: repo_section = f"""

Repository check

{repo_rows}
Repo ID Status Detail
""" if repo_status.get("note"): repo_section += f'

{repo_status["note"]}

' return f"""
NS8 Backup Monitor — {label}: {outcome}
{ts} • Plans: {plan_ids}

{correlation['succeeded']} module(s) OK  |  {correlation['failed']} FAILED  |  {correlation['total']} total

{rows}
Module Backup ID Timestamp Error
{repo_section}
""" def send_notification( config: dict, alerts: list, correlation: dict, repo_status: Optional[dict] = None ): outcome = correlation["outcome"] mail_cfg = config.get("mail", {}) subject_prefix = mail_cfg.get("subject_prefix", "[NS8 Backup]") subject = ( f"{subject_prefix} {OUTCOME_LABEL[outcome]}: {outcome} - " f"{datetime.now(timezone.utc).strftime('%Y-%m-%d')}" ) mail_to = mail_cfg.get("to", []) if not mail_to: log.error("No mail.to recipients configured - cannot send notification") return mail_from = mail_cfg.get("from", "") text_body = _build_text(correlation, repo_status) html_body = _build_html(correlation, repo_status) msg = MIMEMultipart("alternative") msg["Subject"] = subject msg["To"] = ", ".join(mail_to) if mail_from: msg["From"] = mail_from msg.attach(MIMEText(text_body, "plain")) msg.attach(MIMEText(html_body, "html")) try: binary = _sendmail_binary() except FileNotFoundError as e: log.error(str(e)) return try: 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()}" ) else: log.info(f"Notification sent via {binary}: {subject} -> {mail_to}") except subprocess.TimeoutExpired: log.error(f"ns8-sendmail timed out after 30s") except Exception as e: log.error(f"Failed to invoke {binary}: {e}")