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