refactor: notifier - use ns8-sendmail instead of smtplib, drop smtp_config dependency

This commit is contained in:
2026-05-18 20:06:01 +00:00
parent 661756a466
commit 8ce60efd66
+46 -40
View File
@@ -1,27 +1,22 @@
#!/usr/bin/env python3
"""
notifier.py - Sends a single classified notification email.
notifier.py - Sends a single classified notification email via ns8-sendmail.
Formats a human-readable HTML + text email based on:
- correlation outcome (SUCCESS / PARTIAL / REPO_FAILURE)
- per-module statuses
- repository check results (if run)
Delivery strategy:
1. ns8-sendmail -t (uses NS8 configured relay - preferred)
2. sendmail -t (fallback if ns8-sendmail not found)
SMTP configuration is resolved via smtp_config.resolve_smtp_config():
1. NS8 cluster Redis (cluster/mail_settings) - same relay used by NS8 itself
2. config.yml [smtp] section
3. localhost:25 fallback
No SMTP configuration needed: ns8-sendmail already knows the relay.
"""
import logging
import smtplib
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
from .smtp_config import resolve_smtp_config
log = logging.getLogger(__name__)
OUTCOME_LABEL = {
@@ -37,6 +32,15 @@ OUTCOME_COLOR = {
}
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 = [
@@ -67,7 +71,7 @@ def _build_text(correlation: dict, repo_status: Optional[dict]) -> str:
return "\n".join(lines)
def _build_html(correlation: dict, repo_status: Optional[dict], smtp_source: str) -> str:
def _build_html(correlation: dict, repo_status: Optional[dict]) -> str:
outcome = correlation["outcome"]
color = OUTCOME_COLOR[outcome]
label = OUTCOME_LABEL[outcome]
@@ -115,7 +119,7 @@ def _build_html(correlation: dict, repo_status: Optional[dict], smtp_source: str
if repo_status.get("note"):
repo_section += f'<p style="color:#777;font-size:12px">{repo_status["note"]}</p>'
html = f"""
return f"""
<html><body style="font-family:monospace;font-size:14px;max-width:800px;margin:auto">
<div style="background:{color};color:#fff;padding:16px 20px;border-radius:6px 6px 0 0">
<b>NS8 Backup Monitor &mdash; {label}: {outcome}</b><br>
@@ -138,11 +142,9 @@ def _build_html(correlation: dict, repo_status: Optional[dict], smtp_source: str
<tbody>{rows}</tbody>
</table>
{repo_section}
<p style="color:#aaa;font-size:11px;margin-top:24px">Sent via {smtp_source}</p>
</div>
</body></html>
"""
return html
def send_notification(
@@ -154,45 +156,49 @@ def send_notification(
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} - {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 in config.yml - cannot send notification")
log.error("No mail.to recipients configured - cannot send notification")
return
# Resolve SMTP: NS8 Redis relay -> config.yml -> localhost:25
smtp_cfg, mail_from = resolve_smtp_config(config)
smtp_source = f"{smtp_cfg['host']}:{smtp_cfg['port']}"
mail_from = mail_cfg.get("from", "")
text_body = _build_text(correlation, repo_status)
html_body = _build_html(correlation, repo_status, smtp_source)
html_body = _build_html(correlation, repo_status)
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = mail_from
msg["To"] = ", ".join(mail_to)
if mail_from:
msg["From"] = mail_from
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:
binary = _sendmail_binary()
except FileNotFoundError as e:
log.error(str(e))
return
try:
if use_tls:
smtp = smtplib.SMTP_SSL(host, port)
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:
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} (via {smtp_source})")
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 send notification via {smtp_source}: {e}")
log.error(f"Failed to invoke {binary}: {e}")