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