Initial commit: AVWatch v1.0.0 with .gitignore and CLAUDE.md
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
"""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)}"
|
||||
Reference in New Issue
Block a user