Files
AV-Watch/avwatch/report/emailer.py
T

143 lines
4.7 KiB
Python
Raw Normal View History

"""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)}"