v0.152.0
  1"""SMTP email backend class."""
  2
  3from __future__ import annotations
  4
  5import smtplib
  6import ssl
  7import threading
  8from functools import cached_property
  9from typing import TYPE_CHECKING, Any
 10
 11from opentelemetry import trace
 12from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE
 13from opentelemetry.semconv.attributes.server_attributes import (
 14    SERVER_ADDRESS,
 15    SERVER_PORT,
 16)
 17from opentelemetry.trace import SpanKind
 18
 19from plain.runtime import settings
 20from plain.utils.otel import format_exception_type
 21
 22from ..backends.base import BaseEmailBackend
 23from ..message import _sanitize_address
 24from ..utils import _DNS_NAME
 25
 26if TYPE_CHECKING:
 27    from ..message import EmailMessage
 28
 29tracer = trace.get_tracer("plain.email")
 30
 31
 32class EmailBackend(BaseEmailBackend):
 33    """
 34    A wrapper that manages the SMTP network connection.
 35    """
 36
 37    def __init__(
 38        self,
 39        host: str | None = None,
 40        port: int | None = None,
 41        username: str | None = None,
 42        password: str | None = None,
 43        use_tls: bool | None = None,
 44        use_ssl: bool | None = None,
 45        timeout: int | None = None,
 46        ssl_keyfile: str | None = None,
 47        ssl_certfile: str | None = None,
 48    ) -> None:
 49        self.host = host or settings.EMAIL_HOST
 50        self.port = port or settings.EMAIL_PORT
 51        self.username = settings.EMAIL_HOST_USER if username is None else username
 52        self.password = settings.EMAIL_HOST_PASSWORD if password is None else password
 53        self.use_tls = settings.EMAIL_USE_TLS if use_tls is None else use_tls
 54        self.use_ssl = settings.EMAIL_USE_SSL if use_ssl is None else use_ssl
 55        self.timeout = settings.EMAIL_TIMEOUT if timeout is None else timeout
 56        self.ssl_keyfile = (
 57            settings.EMAIL_SSL_KEYFILE if ssl_keyfile is None else ssl_keyfile
 58        )
 59        self.ssl_certfile = (
 60            settings.EMAIL_SSL_CERTFILE if ssl_certfile is None else ssl_certfile
 61        )
 62        if self.use_ssl and self.use_tls:
 63            raise ValueError(
 64                "EMAIL_USE_TLS/EMAIL_USE_SSL are mutually exclusive, so only set "
 65                "one of those settings to True."
 66            )
 67        self.connection = None
 68        self._lock = threading.RLock()
 69
 70    @property
 71    def connection_class(self) -> type[smtplib.SMTP]:
 72        return smtplib.SMTP_SSL if self.use_ssl else smtplib.SMTP
 73
 74    @cached_property
 75    def ssl_context(self) -> ssl.SSLContext:
 76        if self.ssl_certfile or self.ssl_keyfile:
 77            ssl_context = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_CLIENT)
 78            ssl_context.load_cert_chain(self.ssl_certfile, self.ssl_keyfile)
 79            return ssl_context
 80        else:
 81            return ssl.create_default_context()
 82
 83    def open(self) -> bool:
 84        """
 85        Ensure an open connection to the email server. Return whether or not a
 86        new connection was required (True or False).
 87        """
 88        if self.connection:
 89            # Nothing to do if the connection is already open.
 90            return False
 91
 92        # If local_hostname is not specified, socket.getfqdn() gets used.
 93        # For performance, we use the cached FQDN for local_hostname.
 94        connection_params: dict[str, Any] = {"local_hostname": _DNS_NAME.get_fqdn()}
 95        if self.timeout is not None:
 96            connection_params["timeout"] = self.timeout
 97        if self.use_ssl:
 98            connection_params["context"] = self.ssl_context
 99        self.connection = self.connection_class(
100            self.host, self.port, **connection_params
101        )
102
103        # TLS/SSL are mutually exclusive, so only attempt TLS over
104        # non-secure connections.
105        if not self.use_ssl and self.use_tls:
106            self.connection.starttls(context=self.ssl_context)
107        if self.username and self.password:
108            self.connection.login(self.username, self.password)
109        return True
110
111    def close(self) -> None:
112        """Close the connection to the email server."""
113        if self.connection is None:
114            return
115        try:
116            try:
117                self.connection.quit()
118            except (ssl.SSLError, smtplib.SMTPServerDisconnected):
119                # This happens when calling quit() on a TLS connection
120                # sometimes, or when the connection was already disconnected
121                # by the server.
122                self.connection.close()
123        finally:
124            self.connection = None
125
126    def send_messages(self, email_messages: list[EmailMessage]) -> int:
127        """
128        Send one or more EmailMessage objects and return the number of email
129        messages sent.
130        """
131        if not email_messages:
132            return 0
133        with self._lock:
134            new_conn_created = self.open()
135            num_sent = 0
136            try:
137                for message in email_messages:
138                    sent = self._send(message)
139                    if sent:
140                        num_sent += 1
141            finally:
142                if new_conn_created:
143                    self.close()
144        return num_sent
145
146    def _send(self, email_message: EmailMessage) -> bool:
147        """A helper method that does the actual sending."""
148        if not email_message.recipients():
149            return False
150
151        attrs: dict[str, Any] = {
152            "email.system": "smtp",
153            "email.recipients.count": len(email_message.recipients()),
154            "email.has_attachments": bool(email_message.attachments),
155        }
156        if self.host:
157            attrs[SERVER_ADDRESS] = self.host
158        if self.port:
159            attrs[SERVER_PORT] = self.port
160
161        with tracer.start_as_current_span(
162            "email.send",
163            kind=SpanKind.CLIENT,
164            attributes=attrs,
165        ) as span:
166            encoding = email_message.encoding or "utf-8"
167            from_email = _sanitize_address(email_message.from_email, encoding)
168            recipients = [
169                _sanitize_address(addr, encoding) for addr in email_message.recipients()
170            ]
171            message = email_message.message()
172            assert self.connection is not None, (
173                "connection should be open before sending"
174            )
175            try:
176                self.connection.sendmail(
177                    from_email, recipients, message.as_bytes(linesep="\r\n")
178                )
179            except Exception as exc:
180                # record_exception + set_status(ERROR) handled by the
181                # context manager when the exception propagates out.
182                # We only need to set error.type (SDK doesn't do this).
183                span.set_attribute(ERROR_TYPE, format_exception_type(exc))
184                raise
185            return True