Files

87 lines
3.4 KiB
Python
Raw Permalink Normal View History

"""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]