"""Send one message through the relay. STARTTLS verified against SMTP_CA_BUNDLE, never a plain-text fallback. To: is the From address; the real recipients go in the envelope only, so nobody sees the list. No credentials. """ from __future__ import annotations import smtplib import ssl from collections.abc import Sequence from email.message import EmailMessage from email.utils import formatdate, make_msgid from ..settings import Settings TIMEOUT_SECONDS = 15 MAIL_OFF = "Mail is off: SMTP_HOST is empty." class MailError(Exception): """The relay could not take the message. The text is safe to store and show (no secrets).""" def tls_context(settings: Settings) -> ssl.SSLContext: """The same verified context the doctor uses: system trust plus SMTP_CA_BUNDLE when set.""" return ssl.create_default_context(cafile=str(settings.smtp_ca_bundle) if settings.smtp_ca_bundle else None) def subject_prefix(settings: Settings) -> str: return "[Packet Station]" if settings.stack == "prod" else "[Packet Station TEST]" def full_subject(settings: Settings, title: str) -> str: return f"{subject_prefix(settings)} {title}".strip()[:250] def build_message(settings: Settings, subject: str, body: str) -> EmailMessage: msg = EmailMessage() msg["From"] = settings.smtp_from msg["To"] = settings.smtp_from msg["Subject"] = subject msg["Date"] = formatdate(localtime=False) domain = settings.smtp_from.rpartition("@")[2] or None msg["Message-ID"] = make_msgid(domain=domain) msg["Auto-Submitted"] = "auto-generated" msg.set_content(body or subject) return msg def send(settings: Settings, subject: str, body: str, to: Sequence[str]) -> str | None: """Deliver one message to the envelope addresses `to`. Raises MailError when nothing was delivered. Returns None, or a note naming the addresses the relay refused when it took the message for the rest.""" host = (settings.smtp_host or "").strip() if not host: raise MailError(MAIL_OFF) if not to: raise MailError("No recipients.") msg = build_message(settings, subject, body) try: with smtplib.SMTP(host, settings.smtp_port, timeout=TIMEOUT_SECONDS) as smtp: smtp.ehlo() if settings.smtp_starttls: # starttls raises SMTPNotSupportedError when the relay does not offer it: no fallback smtp.starttls(context=tls_context(settings)) smtp.ehlo() refused = smtp.send_message(msg, from_addr=settings.smtp_from, to_addrs=list(to)) except ssl.SSLError as e: raise MailError(f"TLS: {e.reason or e}") from None except ssl.CertificateError as e: raise MailError(f"TLS: {e}") from None except smtplib.SMTPNotSupportedError: raise MailError("The relay does not offer STARTTLS; not sent.") from None except smtplib.SMTPRecipientsRefused as e: raise MailError(f"Every recipient was refused ({len(e.recipients)}).") from None except smtplib.SMTPResponseException as e: raise MailError(f"SMTP {e.smtp_code}: {_text(e.smtp_error)}") from None except (OSError, smtplib.SMTPException) as e: raise MailError(f"{type(e).__name__}: {e}") from None if refused: return f"Refused for {len(refused)} of {len(to)} recipients: {', '.join(sorted(refused))[:300]}" return None def _text(value: bytes | str) -> str: return (value.decode("utf-8", "replace") if isinstance(value, bytes) else str(value))[:300]