1"""SMTP email backend class."""
2import smtplib
3import ssl
4import threading
5
6from plain.runtime import settings
7from plain.utils.functional import cached_property
8
9from ..backends.base import BaseEmailBackend
10from ..message import sanitize_address
11from ..utils import DNS_NAME
12
13
14class EmailBackend(BaseEmailBackend):
15 """
16 A wrapper that manages the SMTP network connection.
17 """
18
19 def __init__(
20 self,
21 host=None,
22 port=None,
23 username=None,
24 password=None,
25 use_tls=None,
26 fail_silently=False,
27 use_ssl=None,
28 timeout=None,
29 ssl_keyfile=None,
30 ssl_certfile=None,
31 **kwargs,
32 ):
33 super().__init__(fail_silently=fail_silently)
34 self.host = host or settings.EMAIL_HOST
35 self.port = port or settings.EMAIL_PORT
36 self.username = settings.EMAIL_HOST_USER if username is None else username
37 self.password = settings.EMAIL_HOST_PASSWORD if password is None else password
38 self.use_tls = settings.EMAIL_USE_TLS if use_tls is None else use_tls
39 self.use_ssl = settings.EMAIL_USE_SSL if use_ssl is None else use_ssl
40 self.timeout = settings.EMAIL_TIMEOUT if timeout is None else timeout
41 self.ssl_keyfile = (
42 settings.EMAIL_SSL_KEYFILE if ssl_keyfile is None else ssl_keyfile
43 )
44 self.ssl_certfile = (
45 settings.EMAIL_SSL_CERTFILE if ssl_certfile is None else ssl_certfile
46 )
47 if self.use_ssl and self.use_tls:
48 raise ValueError(
49 "EMAIL_USE_TLS/EMAIL_USE_SSL are mutually exclusive, so only set "
50 "one of those settings to True."
51 )
52 self.connection = None
53 self._lock = threading.RLock()
54
55 @property
56 def connection_class(self):
57 return smtplib.SMTP_SSL if self.use_ssl else smtplib.SMTP
58
59 @cached_property
60 def ssl_context(self):
61 if self.ssl_certfile or self.ssl_keyfile:
62 ssl_context = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_CLIENT)
63 ssl_context.load_cert_chain(self.ssl_certfile, self.ssl_keyfile)
64 return ssl_context
65 else:
66 return ssl.create_default_context()
67
68 def open(self):
69 """
70 Ensure an open connection to the email server. Return whether or not a
71 new connection was required (True or False) or None if an exception
72 passed silently.
73 """
74 if self.connection:
75 # Nothing to do if the connection is already open.
76 return False
77
78 # If local_hostname is not specified, socket.getfqdn() gets used.
79 # For performance, we use the cached FQDN for local_hostname.
80 connection_params = {"local_hostname": DNS_NAME.get_fqdn()}
81 if self.timeout is not None:
82 connection_params["timeout"] = self.timeout
83 if self.use_ssl:
84 connection_params["context"] = self.ssl_context
85 try:
86 self.connection = self.connection_class(
87 self.host, self.port, **connection_params
88 )
89
90 # TLS/SSL are mutually exclusive, so only attempt TLS over
91 # non-secure connections.
92 if not self.use_ssl and self.use_tls:
93 self.connection.starttls(context=self.ssl_context)
94 if self.username and self.password:
95 self.connection.login(self.username, self.password)
96 return True
97 except OSError:
98 if not self.fail_silently:
99 raise
100
101 def close(self):
102 """Close the connection to the email server."""
103 if self.connection is None:
104 return
105 try:
106 try:
107 self.connection.quit()
108 except (ssl.SSLError, smtplib.SMTPServerDisconnected):
109 # This happens when calling quit() on a TLS connection
110 # sometimes, or when the connection was already disconnected
111 # by the server.
112 self.connection.close()
113 except smtplib.SMTPException:
114 if self.fail_silently:
115 return
116 raise
117 finally:
118 self.connection = None
119
120 def send_messages(self, email_messages):
121 """
122 Send one or more EmailMessage objects and return the number of email
123 messages sent.
124 """
125 if not email_messages:
126 return 0
127 with self._lock:
128 new_conn_created = self.open()
129 if not self.connection or new_conn_created is None:
130 # We failed silently on open().
131 # Trying to send would be pointless.
132 return 0
133 num_sent = 0
134 try:
135 for message in email_messages:
136 sent = self._send(message)
137 if sent:
138 num_sent += 1
139 finally:
140 if new_conn_created:
141 self.close()
142 return num_sent
143
144 def _send(self, email_message):
145 """A helper method that does the actual sending."""
146 if not email_message.recipients():
147 return False
148 encoding = email_message.encoding or settings.DEFAULT_CHARSET
149 from_email = sanitize_address(email_message.from_email, encoding)
150 recipients = [
151 sanitize_address(addr, encoding) for addr in email_message.recipients()
152 ]
153 message = email_message.message()
154 try:
155 self.connection.sendmail(
156 from_email, recipients, message.as_bytes(linesep="\r\n")
157 )
158 except smtplib.SMTPException:
159 if not self.fail_silently:
160 raise
161 return False
162 return True