From fd7723a9f8ab685a55a74015ce0a102cddb59307 Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 18 May 2026 15:13:06 +0000 Subject: [PATCH] feat: add unified email notifier with classified outcome --- ns8_backup_monitor/notifier.py | 185 +++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 ns8_backup_monitor/notifier.py diff --git a/ns8_backup_monitor/notifier.py b/ns8_backup_monitor/notifier.py new file mode 100644 index 0000000..ed3fa0a --- /dev/null +++ b/ns8_backup_monitor/notifier.py @@ -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'' + 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 IDStatusDetail
+ """ + if repo_status.get("note"): + repo_section += f'

{repo_status["note"]}

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

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

+ + + + + + + + + {rows} +
ModuleBackup IDTimestampError
+ {repo_section} +
+ + """ + 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}")