From 8e8c9d8d6f4e80f00a6b4af0fc124439414ecc4a Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 18 May 2026 20:29:55 +0000 Subject: [PATCH] fix: use correct ns8-sendmail signature (runagent ns8-sendmail -s -f <to>) --- ns8_backup_monitor/notifier.py | 196 +++++++++++---------------------- 1 file changed, 64 insertions(+), 132 deletions(-) diff --git a/ns8_backup_monitor/notifier.py b/ns8_backup_monitor/notifier.py index 1bb2844..1c6efa7 100644 --- a/ns8_backup_monitor/notifier.py +++ b/ns8_backup_monitor/notifier.py @@ -1,44 +1,34 @@ #!/usr/bin/env python3 """ -notifier.py - Sends a single classified notification email via ns8-sendmail. +notifier.py - Sends email via ns8-sendmail. -Delivery strategy: - 1. ns8-sendmail -t (uses NS8 configured relay - preferred) - 2. sendmail -t (fallback if ns8-sendmail not found) +Correct invocation (from working reference code): + runagent ns8-sendmail -s -f [ ...] -No SMTP configuration needed: ns8-sendmail already knows the relay. +Body is passed on stdin as plain text. +ns8-sendmail does NOT read To:/From:/Subject: from headers. """ - 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", -} +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 _send_cmd(subject: str, mail_from: str, mail_to: list) -> list: + """ + Build argv for ns8-sendmail. + runagent must be in PATH (it is, for root on NS8 nodes). + """ + cmd = ["runagent", "ns8-sendmail", "-s", subject] + if mail_from: + cmd += ["-f", mail_from] + cmd += mail_to + return cmd def _build_text(correlation: dict, repo_status: Optional[dict]) -> str: @@ -46,17 +36,15 @@ def _build_text(correlation: dict, repo_status: Optional[dict]) -> str: 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"Plans: {', '.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(f" - [{m['module_id']}] {m['backup_id']}: {m.get('error', '?')}") lines.append("") - if repo_status: lines.append("Repository check:") for dest in repo_status.get("destinations", []): @@ -64,10 +52,8 @@ def _build_text(correlation: dict, repo_status: Optional[dict]) -> str: 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) @@ -77,128 +63,74 @@ def _build_html(correlation: dict, repo_status: Optional[dict]) -> str: 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 ""}' - '' - ) - + rows += (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 = "" + rr = "" 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
- """ + rr += (f'{dest["repo_id"]}' + f'{dest["status"]}' + f'{dest.get("error","")}') + if rr: + repo_section = ("

Repository check

" + "" + "" + "" + f"{rr}
RepoStatusDetail
") 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} -
ModuleBackup IDTimestampError
- {repo_section} -
- - """ + repo_section += f"

{repo_status['note']}

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

{correlation['succeeded']} OK  | " + f"{correlation['failed']} FAILED  | " + f"{correlation['total']} total

" + "" + "" + "" + "" + f"{rows}
ModuleBackup IDTimestampError
{repo_section}
") -def send_notification( - config: dict, - alerts: list, - correlation: dict, - repo_status: Optional[dict] = None -): +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')}" - ) + 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") + log.error("No mail.to recipients configured") 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")) + # ns8-sendmail accepts plain text on stdin; send the text body + body = _build_text(correlation, repo_status) + cmd = _send_cmd(subject, mail_from, mail_to) + log.debug("send cmd: %s", cmd) 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, - ) + proc = subprocess.run(cmd, input=body, text=True, capture_output=True, timeout=30) if proc.returncode != 0: - log.error( - f"ns8-sendmail exited {proc.returncode}: {proc.stderr.decode().strip()}" - ) + err = proc.stderr.strip() or proc.stdout.strip() or f"exit {proc.returncode}" + log.error("ns8-sendmail failed: %s", err) else: - log.info(f"Notification sent via {binary}: {subject} -> {mail_to}") + log.info("notification sent: %s -> %s", subject, mail_to) + except FileNotFoundError: + log.error("'runagent' not found in PATH - is this an NS8 node?") except subprocess.TimeoutExpired: - log.error(f"ns8-sendmail timed out after 30s") + log.error("ns8-sendmail timed out after 30s") except Exception as e: - log.error(f"Failed to invoke {binary}: {e}") + log.error("failed to send notification: %s", e)