fdd0426884
- #1 XSS: escape user-controlled values (location name, baseline values, recent- report fields, SMTP status message) in the modals via the existing _mergeEsc helper — they were concatenated raw into innerHTML (stored XSS via location name). - #2 SMTP: an unrecognized REPORT_SMTP_SECURITY no longer silently downgrades to a plaintext connection while still calling login() — it falls back to starttls and warns; warn on intentional security=none + auth. - #3 scheduler: run the (blocking smtplib + Excel) nightly report in a worker thread (asyncio.to_thread + its own DB session) so it can't stall the loop that drives time-sensitive device cycles. New _run_one_report helper. - #4 cycle ingest: set unit_id on the ingested data session (ingest_nrl_zip leaves it None) before dropping the empty placeholder, preserving the unit<->session link; repoint old_session_id at the real row. - #7 robustness: wrap send_report_email in the orchestrator and run_nightly_report in /view + /run so a render/SMTP error returns a clean error instead of a raw 500 after artifacts are written. Verified: SMTP paths (typo->starttls, none, starttls, ssl), off-thread tick stamps last_run_date + writes the file, /view 200, escaping wired, app imports. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
173 lines
6.4 KiB
Python
173 lines
6.4 KiB
Python
"""
|
|
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 <monitoring@terra-mechanics.com>"
|
|
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
|