#!/usr/bin/env python3 """Build and send backup outcome email notifications via ns8-sendmail. 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``. 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 from typing import Optional log = logging.getLogger(__name__) # --------------------------------------------------------------------------- # 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 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: cmd += ["-f", mail_from] cmd += mail_to 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 / " 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', '')}" ) 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: """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