"""SMTP delivery of the digest. Works with Microsoft 365 and Google Workspace, both of which need an app-specific password (or a mailbox with SMTP AUTH enabled) -- your normal account password will be rejected if MFA is on. See README for the exact settings for each. """ from __future__ import annotations import logging import smtplib import ssl from email.message import EmailMessage from email.utils import formataddr, formatdate, make_msgid from pathlib import Path from ..config import EmailConfig log = logging.getLogger(__name__) class EmailError(RuntimeError): pass def validate(cfg: EmailConfig, dry_run: bool = False) -> list[str]: """Return a list of configuration problems, empty if it looks sendable. ``dry_run`` relaxes the credential check: a dry run never opens a connection, so demanding a mail password before it will even build the report is pure obstruction -- and it hides the report the person was actually trying to look at. """ problems: list[str] = [] if not cfg.enabled: problems.append("email.enabled is false in config.toml") if not cfg.smtp_host: problems.append("email.smtp_host is not set") if not cfg.from_addr: problems.append("email.from_addr is not set") if not cfg.to: problems.append("email.to has no recipients") if cfg.username and not cfg.password and not dry_run: problems.append( "email.username is set but no password found -- set the " "AVWATCH_SMTP_PASSWORD environment variable" ) if cfg.use_ssl and cfg.use_starttls: problems.append("use_ssl and use_starttls are mutually exclusive") return problems def build_message( cfg: EmailConfig, subject: str, html_body: str, text_body: str, attachment: Path | None = None, ) -> EmailMessage: msg = EmailMessage() msg["Subject"] = f"{cfg.subject_prefix} {subject}".strip() msg["From"] = formataddr((cfg.from_name, cfg.from_addr)) msg["To"] = ", ".join(cfg.to) if cfg.cc: msg["Cc"] = ", ".join(cfg.cc) msg["Date"] = formatdate(localtime=True) msg["Message-ID"] = make_msgid(domain=cfg.from_addr.split("@")[-1] or None) # Digests are informational; keep them out of vacation-responder loops. msg["Auto-Submitted"] = "auto-generated" msg.set_content(text_body) msg.add_alternative(html_body, subtype="html") if attachment and cfg.attach_html: path = Path(attachment) if path.exists(): msg.add_attachment( path.read_bytes(), maintype="text", subtype="html", filename=path.name, ) else: log.warning("attachment not found, sending without it: %s", path) return msg def send( cfg: EmailConfig, subject: str, html_body: str, text_body: str, attachment: Path | None = None, dry_run: bool = False, ) -> str: """Send the digest. Returns a human-readable result line.""" problems = validate(cfg, dry_run=dry_run) if problems: raise EmailError("email not configured: " + "; ".join(problems)) msg = build_message(cfg, subject, html_body, text_body, attachment) recipients = list(cfg.to) + list(cfg.cc) if dry_run: return ( f"[dry run] would send {msg['Subject']!r} to " f"{', '.join(recipients)} via {cfg.smtp_host}:{cfg.smtp_port}" ) context = ssl.create_default_context() try: if cfg.use_ssl: with smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, context=context, timeout=60) as smtp: if cfg.username: smtp.login(cfg.username, cfg.password) smtp.send_message(msg, to_addrs=recipients) else: with smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=60) as smtp: smtp.ehlo() if cfg.use_starttls: smtp.starttls(context=context) smtp.ehlo() if cfg.username: smtp.login(cfg.username, cfg.password) smtp.send_message(msg, to_addrs=recipients) except smtplib.SMTPAuthenticationError as exc: raise EmailError( f"SMTP rejected the login ({exc.smtp_code}). With Microsoft 365 or " "Google Workspace you need an app password, not the account " "password, and SMTP AUTH must be enabled for the mailbox." ) from exc except smtplib.SMTPException as exc: raise EmailError(f"SMTP error: {exc}") from exc except OSError as exc: raise EmailError( f"could not reach {cfg.smtp_host}:{cfg.smtp_port}: {exc}" ) from exc return f"sent {msg['Subject']!r} to {', '.join(recipients)}"