from __future__ import annotations import copy import smtplib import ssl from dataclasses import dataclass from email.message import EmailMessage from email.utils import formataddr from app.mailer.campaign.models import SmtpConfig, TransportSecurity class SmtpConfigurationError(ValueError): """Raised when SMTP settings are incomplete or inconsistent.""" class SmtpSendError(RuntimeError): """Raised when an SMTP send attempt fails.""" @dataclass(frozen=True, slots=True) class SmtpSendResult: host: str port: int security: str envelope_from: str envelope_recipients: list[str] refused_recipients: dict[str, tuple[int, bytes | str]] @property def accepted_count(self) -> int: return len(self.envelope_recipients) - len(self.refused_recipients) def _require_smtp_config(config: SmtpConfig) -> tuple[str, int]: if not config.host: raise SmtpConfigurationError("SMTP host is required") if not config.port: raise SmtpConfigurationError("SMTP port is required") if bool(config.username) != bool(config.password): raise SmtpConfigurationError("SMTP username and password must be provided together, or both omitted") return config.host, config.port def _open_smtp(config: SmtpConfig) -> smtplib.SMTP: host, port = _require_smtp_config(config) context = ssl.create_default_context() try: if config.security == TransportSecurity.TLS: smtp: smtplib.SMTP = smtplib.SMTP_SSL(host=host, port=port, timeout=config.timeout_seconds, context=context) smtp.ehlo() else: smtp = smtplib.SMTP(host=host, port=port, timeout=config.timeout_seconds) smtp.ehlo() if config.security == TransportSecurity.STARTTLS: smtp.starttls(context=context) smtp.ehlo() if config.username and config.password: smtp.login(config.username, config.password) return smtp except Exception: # If construction/login fails after a socket was created, smtplib usually closes # on GC, but explicit cleanup is safer when the variable exists. try: smtp.quit() # type: ignore[possibly-undefined] except Exception: pass raise def _decode_refused(refused: dict[str, tuple[int, bytes]]) -> dict[str, tuple[int, bytes | str]]: normalized: dict[str, tuple[int, bytes | str]] = {} for recipient, (code, response) in refused.items(): try: normalized[recipient] = (code, response.decode("utf-8", errors="replace")) except AttributeError: normalized[recipient] = (code, response) return normalized def prepare_test_message( message: EmailMessage, *, test_recipient: str, test_recipient_name: str | None = None, ) -> EmailMessage: """Return a safe copy of a generated campaign message for test delivery. The original recipient headers are removed so a test send cannot accidentally leak the real To/Cc list or deliver to the real recipients. The envelope recipient must also be supplied separately to send_email_message(). """ test_message = copy.deepcopy(message) for header in ["To", "Cc", "Bcc"]: if header in test_message: del test_message[header] # Replace potential previous marker headers if the user test-sends an EML twice. for header in ["X-MultiMailer-Test-Send"]: if header in test_message: del test_message[header] test_message["To"] = formataddr((test_recipient_name or test_recipient, test_recipient)) test_message["X-MultiMailer-Test-Send"] = "true" return test_message def send_email_message( message: EmailMessage, *, smtp_config: SmtpConfig, envelope_from: str, envelope_recipients: list[str], ) -> SmtpSendResult: """Send an EmailMessage through SMTP. This low-level function deliberately receives explicit envelope sender and recipients. Headers and SMTP envelope are related but not identical; Bcc and future bounce-address handling depend on keeping them separate. """ host, port = _require_smtp_config(smtp_config) if not envelope_from: raise SmtpConfigurationError("SMTP envelope sender is required") if not envelope_recipients: raise SmtpConfigurationError("at least one SMTP envelope recipient is required") try: with _open_smtp(smtp_config) as smtp: refused = smtp.send_message( message, from_addr=envelope_from, to_addrs=envelope_recipients, ) except smtplib.SMTPAuthenticationError as exc: raise SmtpSendError(f"SMTP authentication failed: {exc.smtp_code} {exc.smtp_error!r}") from exc except smtplib.SMTPRecipientsRefused as exc: raise SmtpSendError(f"all SMTP recipients were refused: {_decode_refused(exc.recipients)}") from exc except smtplib.SMTPSenderRefused as exc: raise SmtpSendError(f"SMTP sender was refused: {exc.smtp_code} {exc.smtp_error!r}") from exc except smtplib.SMTPResponseException as exc: raise SmtpSendError(f"SMTP error: {exc.smtp_code} {exc.smtp_error!r}") from exc except (OSError, smtplib.SMTPException) as exc: raise SmtpSendError(f"SMTP send failed: {exc}") from exc return SmtpSendResult( host=host, port=port, security=smtp_config.security.value, envelope_from=envelope_from, envelope_recipients=list(envelope_recipients), refused_recipients=_decode_refused(refused), )