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 from app.mailer.dev.mock_mailbox import ( consume_fail_next_smtp, get_failures, is_mock_smtp_host, record_smtp_delivery, ) 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 SmtpLoginTestResult: host: str port: int security: str authenticated: bool @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 test_smtp_login(*, smtp_config: SmtpConfig) -> SmtpLoginTestResult: """Open an SMTP connection and authenticate if credentials are configured. This is intentionally side-effect free: it does not send a message and it never receives envelope or recipient data. It is used by the WebUI to check whether the configured transport can be reached before a campaign is built or queued. """ host, port = _require_smtp_config(smtp_config) if is_mock_smtp_host(smtp_config.host): host, port = _require_smtp_config(smtp_config) return SmtpLoginTestResult( host=host, port=port, security=smtp_config.security.value, authenticated=bool(smtp_config.username and smtp_config.password), ) smtp = _open_smtp(smtp_config) try: return SmtpLoginTestResult( host=host, port=port, security=smtp_config.security.value, authenticated=bool(smtp_config.username and smtp_config.password), ) finally: try: smtp.quit() except Exception: try: smtp.close() except Exception: pass 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") if is_mock_smtp_host(smtp_config.host): if consume_fail_next_smtp(): raise SmtpSendError("Mock SMTP configured to fail the next send") failures = get_failures() reject_text = str(failures.get("smtp_reject_recipients_containing") or "").strip().lower() refused: dict[str, tuple[int, bytes]] = {} accepted = list(envelope_recipients) if reject_text: refused = { recipient: (550, b"mock recipient rejected") for recipient in envelope_recipients if reject_text in recipient.lower() } accepted = [recipient for recipient in envelope_recipients if recipient not in refused] if not accepted: raise SmtpSendError(f"all mock SMTP recipients were refused: {_decode_refused(refused)}") record_smtp_delivery( message, envelope_from=envelope_from, envelope_recipients=accepted, smtp_host=smtp_config.host, ) 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), ) 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), )