""" Report email sender — config-driven SMTP via the Python standard library. Connection settings come from environment variables so the mail backend (internal relay / Microsoft 365 / Gmail / SendGrid) can be swapped without code changes — see the build plan: terra-mechanics.com is on M365 and has a smarthost relay that already sends the seismograph alerts as remote@terra-mechanics.com; reuse that relay's settings here. DRY-RUN: if SMTP isn't configured (no host/from), the message is built and logged but NOT sent, and the call still succeeds. This keeps report generation working before the relay is wired up, and means a missing/incomplete mail config can never crash the nightly pipeline. Env vars -------- REPORT_SMTP_HOST e.g. smtp.office365.com (unset → dry-run) REPORT_SMTP_PORT default 587 REPORT_SMTP_SECURITY starttls (default) | ssl | none REPORT_SMTP_USER optional — omit for IP-authenticated relays REPORT_SMTP_PASSWORD optional REPORT_SMTP_FROM e.g. "TMI Monitoring " REPORT_SMTP_RECIPIENTS comma-separated default recipient list REPORT_SMTP_TIMEOUT seconds, default 30 """ from __future__ import annotations import logging import os import smtplib import ssl from dataclasses import dataclass, field from email.message import EmailMessage from typing import Optional logger = logging.getLogger(__name__) # Convenient MIME type for the Excel attachment. XLSX_MIME = ("application", "vnd.openxmlformats-officedocument.spreadsheetml.sheet") @dataclass class Attachment: filename: str content: bytes maintype: str = "application" subtype: str = "octet-stream" @dataclass class SMTPConfig: host: str = "" port: int = 587 security: str = "starttls" # "starttls" | "ssl" | "none" user: str = "" password: str = "" sender: str = "" recipients: list[str] = field(default_factory=list) timeout: float = 30.0 @classmethod def from_env(cls) -> "SMTPConfig": rec = os.getenv("REPORT_SMTP_RECIPIENTS", "") return cls( host=os.getenv("REPORT_SMTP_HOST", "").strip(), port=int(os.getenv("REPORT_SMTP_PORT", "587") or 587), security=os.getenv("REPORT_SMTP_SECURITY", "starttls").strip().lower(), user=os.getenv("REPORT_SMTP_USER", "").strip(), password=os.getenv("REPORT_SMTP_PASSWORD", ""), sender=os.getenv("REPORT_SMTP_FROM", "").strip(), recipients=[r.strip() for r in rec.split(",") if r.strip()], timeout=float(os.getenv("REPORT_SMTP_TIMEOUT", "30") or 30), ) @property def configured(self) -> bool: """True only when we have enough to actually send (host + from).""" return bool(self.host and self.sender) def build_message( cfg: SMTPConfig, subject: str, html_body: str, recipients: list[str], attachments: Optional[list[Attachment]] = None, text_body: Optional[str] = None, ) -> EmailMessage: """Assemble a multipart message: plain-text fallback + HTML + attachments.""" msg = EmailMessage() msg["From"] = cfg.sender or "terra-view@localhost" msg["To"] = ", ".join(recipients) msg["Subject"] = subject # Plain-text part first, then the HTML alternative (clients prefer the HTML). msg.set_content(text_body or "This report is best viewed in an HTML email client.") msg.add_alternative(html_body, subtype="html") for att in (attachments or []): msg.add_attachment( att.content, maintype=att.maintype, subtype=att.subtype, filename=att.filename, ) return msg def send_report_email( subject: str, html_body: str, *, attachments: Optional[list[Attachment]] = None, recipients: Optional[list[str]] = None, text_body: Optional[str] = None, cfg: Optional[SMTPConfig] = None, ) -> dict: """Send (or dry-run) the report email. Returns a result dict: {sent, dry_run, recipients, error}. Never raises on a send failure — it logs and returns error, so the orchestrator can record the failure without aborting the rest of the pipeline. """ cfg = cfg or SMTPConfig.from_env() recipients = recipients if recipients is not None else cfg.recipients result = {"sent": False, "dry_run": False, "recipients": recipients, "error": None} if not recipients: result["error"] = "No recipients configured" logger.warning("Report email: no recipients set; skipping send of %r", subject) return result msg = build_message(cfg, subject, html_body, recipients, attachments, text_body) if not cfg.configured: result["dry_run"] = True logger.info( "Report email DRY-RUN (SMTP not configured): would send %r to %s with %d attachment(s)", subject, recipients, len(attachments or []), ) return result # Validate the security mode: an unrecognized value (typo) must NOT silently # fall through to a plaintext connection while still sending credentials. sec = cfg.security if cfg.security in ("ssl", "starttls", "none") else "starttls" if sec != cfg.security: logger.warning("Unknown REPORT_SMTP_SECURITY=%r — falling back to 'starttls'", cfg.security) try: if sec == "ssl": ctx = ssl.create_default_context() with smtplib.SMTP_SSL(cfg.host, cfg.port, timeout=cfg.timeout, context=ctx) as s: if cfg.user: s.login(cfg.user, cfg.password) s.send_message(msg) else: with smtplib.SMTP(cfg.host, cfg.port, timeout=cfg.timeout) as s: s.ehlo() if sec == "starttls": s.starttls(context=ssl.create_default_context()) s.ehlo() if cfg.user: if sec == "none": logger.warning( "Sending SMTP credentials over an UNENCRYPTED connection " "(REPORT_SMTP_SECURITY=none) — set starttls/ssl if the relay supports it." ) s.login(cfg.user, cfg.password) s.send_message(msg) result["sent"] = True logger.info("Report email sent: %r to %s", subject, recipients) except Exception as e: # noqa: BLE001 — surface as result, never abort the pipeline result["error"] = str(e) logger.error("Report email send failed: %s", e, exc_info=True) return result