diff --git a/ns8_backup_monitor/notifier.py b/ns8_backup_monitor/notifier.py index 1c6efa7..994210f 100644 --- a/ns8_backup_monitor/notifier.py +++ b/ns8_backup_monitor/notifier.py @@ -1,13 +1,30 @@ #!/usr/bin/env python3 -""" -notifier.py - Sends email via ns8-sendmail. +"""Build and send backup outcome email notifications via ns8-sendmail. -Correct invocation (from working reference code): - runagent ns8-sendmail -s -f [ ...] +This module is the final stage of the pipeline. It takes the correlation +result and the optional repository health check result, renders both a +plain-text and an HTML email body, and dispatches the message through the +NS8 mail relay using ``runagent ns8-sendmail``. -Body is passed on stdin as plain text. -ns8-sendmail does NOT read To:/From:/Subject: from headers. +Why ns8-sendmail / runagent? +---------------------------- +NS8 modules are containerised; the cluster mail relay is exposed through +the ``runagent`` helper which bridges the host and the container network. +``ns8-sendmail`` reads the relay configuration from the NS8 cluster state, +so no SMTP settings need to be stored in this project's config file. + +Correct invocation (verified against NS8 source): + runagent ns8-sendmail -s [-f ] [ ...] + Body is read from stdin as plain text. + ns8-sendmail does NOT parse To:/From:/Subject: headers from the body. + +Outcome labels and colours used in the HTML email +-------------------------------------------------- + SUCCESS – label "OK", header background #2e7d32 (green) + PARTIAL – label "WARNING", header background #e65100 (orange) + REPO_FAILURE – label "CRITICAL", header background #b71c1c (red) """ + import logging import subprocess from datetime import datetime, timezone @@ -15,14 +32,41 @@ 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 presentation maps +# --------------------------------------------------------------------------- +# Maps the three internal outcome codes to a short label and a background +# colour used in the HTML email header banner. +OUTCOME_LABEL = { + "SUCCESS": "OK", + "PARTIAL": "WARNING", + "REPO_FAILURE": "CRITICAL", +} + +OUTCOME_COLOR = { + "SUCCESS": "#2e7d32", # Material green 800 + "PARTIAL": "#e65100", # Material deep-orange 900 + "REPO_FAILURE": "#b71c1c", # Material red 900 +} + + +# --------------------------------------------------------------------------- +# Command builder +# --------------------------------------------------------------------------- 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). + """Build the argv list for invoking ns8-sendmail via runagent. + + ``runagent`` must be in PATH, which is guaranteed for root on NS8 nodes. + + Args: + subject: Email subject line (already includes prefix and date). + mail_from: Envelope From address (may be empty; ns8-sendmail has a default). + mail_to: List of recipient addresses. + + Returns: + List of strings ready to pass to subprocess.run(). """ cmd = ["runagent", "ns8-sendmail", "-s", subject] if mail_from: @@ -31,106 +75,235 @@ def _send_cmd(subject: str, mail_from: str, mail_to: list) -> list: return cmd +# --------------------------------------------------------------------------- +# Plain-text body renderer +# --------------------------------------------------------------------------- + def _build_text(correlation: dict, repo_status: Optional[dict]) -> str: + """Render a plain-text email body from the correlation and repo results. + + Produces a human-readable report suitable for terminal mail clients and + as a fallback for email clients that do not render HTML. + + Args: + correlation: Dict returned by correlator.correlate_backup_status(). + repo_status: Dict returned by repo_check.check_repositories(), or None. + + Returns: + Multi-line string (Unix line endings). + """ outcome = correlation["outcome"] lines = [ f"NS8 Backup Monitor - {OUTCOME_LABEL[outcome]}: {outcome}", f"Time: {datetime.now(timezone.utc).isoformat()}", f"Plans: {', '.join(correlation.get('backup_ids', []))}", - f"Modules: {correlation['succeeded']} OK / {correlation['failed']} FAILED / {correlation['total']} total", + f"Modules: {correlation['succeeded']} OK / " + f"{correlation['failed']} FAILED / {correlation['total']} total", "", ] + + # List each failed module with its error message. if correlation["failed_modules"]: lines.append("Failed modules:") for m in correlation["failed_modules"]: lines.append(f" - [{m['module_id']}] {m['backup_id']}: {m.get('error', '?')}") lines.append("") + + # Append repository health check details when available. 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', '')}") + 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) +# --------------------------------------------------------------------------- +# HTML body renderer +# --------------------------------------------------------------------------- + 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") + """Render an HTML email body from the correlation and repo results. + + Produces a self-contained HTML document with: + - A coloured header banner showing the outcome and timestamp. + - A summary line with module counts. + - A per-module status table with colour-coded rows (green/red). + - An optional repository check table appended when repo_status is present. + + Inline styles are used throughout to maximise compatibility with + webmail clients that strip