143 lines
4.7 KiB
Python
143 lines
4.7 KiB
Python
"""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)}"
|