"""Sends e-mail through an SMTP server over TLS. The only module that talks SMTP (AD-1).""" from __future__ import annotations import logging import smtplib import ssl from email.message import EmailMessage from email.utils import formatdate, make_msgid from portal.config.env import SmtpConfig from .base import MailMessage, MailRejected, MailUnavailable logger = logging.getLogger(__name__) TIMEOUT_SECONDS = 20 class SmtpMailer: def __init__(self, config: SmtpConfig, smtp_module=smtplib) -> None: self._config = config self._smtp = smtp_module def _build(self, message: MailMessage) -> EmailMessage: email = EmailMessage() # Setting a header that holds a line break raises ValueError, so no header injection. email["From"] = message.sender email["To"] = message.recipient email["Subject"] = message.subject email["Date"] = formatdate(localtime=False) email["Message-ID"] = make_msgid(domain=self._config.host) email.set_content(message.body_text, charset="utf-8") return email def _connect(self): config = self._config context = ssl.create_default_context() # verifies the certificate and the host name if config.security == "ssl": connection = self._smtp.SMTP_SSL( config.host, config.port, timeout=TIMEOUT_SECONDS, context=context ) else: connection = self._smtp.SMTP(config.host, config.port, timeout=TIMEOUT_SECONDS) try: connection.ehlo() if config.security == "starttls": connection.starttls(context=context) connection.ehlo() connection.login(config.username, config.password) except BaseException: connection.close() raise return connection def send(self, message: MailMessage) -> None: try: email = self._build(message) except ValueError as exc: raise MailRejected("The message could not be built") from exc try: connection = self._connect() try: connection.send_message(email) finally: try: connection.quit() except (self._smtp.SMTPException, OSError): connection.close() except self._smtp.SMTPAuthenticationError as exc: # A configuration problem: the Admin fixes it and the retry works. logger.error("SMTP login refused (code %s)", exc.smtp_code) raise MailUnavailable(f"The mail server refused the login ({exc.smtp_code})") from exc except self._smtp.SMTPRecipientsRefused as exc: codes = [code for code, _ in exc.recipients.values()] raise _by_code(max(codes, default=0)) from exc except self._smtp.SMTPResponseException as exc: raise _by_code(exc.smtp_code) from exc except (self._smtp.SMTPException, OSError, ssl.SSLError) as exc: logger.warning("SMTP request failed: %s", type(exc).__name__) name = type(exc).__name__ raise MailUnavailable(f"The mail server is not available ({name})") from exc def _by_code(code: int): """Codes 5xx are permanent; everything else may work later.""" if 500 <= code < 600: return MailRejected(f"The mail server refused the message ({code})") return MailUnavailable(f"The mail server could not take the message ({code})")