plain.email 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plain/email/README.md +15 -0
- plain/email/__init__.py +111 -0
- plain/email/backends/__init__.py +0 -0
- plain/email/backends/base.py +62 -0
- plain/email/backends/console.py +45 -0
- plain/email/backends/filebased.py +65 -0
- plain/email/backends/smtp.py +163 -0
- plain/email/default_settings.py +27 -0
- plain/email/message.py +577 -0
- plain/email/utils.py +22 -0
- plain_email-0.8.0.dist-info/METADATA +26 -0
- plain_email-0.8.0.dist-info/RECORD +14 -0
- plain_email-0.8.0.dist-info/WHEEL +4 -0
- plain_email-0.8.0.dist-info/licenses/LICENSE +61 -0
plain/email/README.md
ADDED
plain/email/__init__.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tools for sending email.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from plain.runtime import settings
|
|
6
|
+
from plain.utils.module_loading import import_string
|
|
7
|
+
|
|
8
|
+
from .message import (
|
|
9
|
+
DEFAULT_ATTACHMENT_MIME_TYPE,
|
|
10
|
+
BadHeaderError,
|
|
11
|
+
EmailMessage,
|
|
12
|
+
EmailMultiAlternatives,
|
|
13
|
+
SafeMIMEMultipart,
|
|
14
|
+
SafeMIMEText,
|
|
15
|
+
TemplateEmail,
|
|
16
|
+
forbid_multi_line_headers,
|
|
17
|
+
make_msgid,
|
|
18
|
+
)
|
|
19
|
+
from .utils import DNS_NAME, CachedDnsName
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"CachedDnsName",
|
|
23
|
+
"DNS_NAME",
|
|
24
|
+
"EmailMessage",
|
|
25
|
+
"EmailMultiAlternatives",
|
|
26
|
+
"TemplateEmail",
|
|
27
|
+
"SafeMIMEText",
|
|
28
|
+
"SafeMIMEMultipart",
|
|
29
|
+
"DEFAULT_ATTACHMENT_MIME_TYPE",
|
|
30
|
+
"make_msgid",
|
|
31
|
+
"BadHeaderError",
|
|
32
|
+
"forbid_multi_line_headers",
|
|
33
|
+
"get_connection",
|
|
34
|
+
"send_mail",
|
|
35
|
+
"send_mass_mail",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_connection(backend=None, fail_silently=False, **kwds):
|
|
40
|
+
"""Load an email backend and return an instance of it.
|
|
41
|
+
|
|
42
|
+
If backend is None (default), use settings.EMAIL_BACKEND.
|
|
43
|
+
|
|
44
|
+
Both fail_silently and other keyword arguments are used in the
|
|
45
|
+
constructor of the backend.
|
|
46
|
+
"""
|
|
47
|
+
klass = import_string(backend or settings.EMAIL_BACKEND)
|
|
48
|
+
return klass(fail_silently=fail_silently, **kwds)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def send_mail(
|
|
52
|
+
subject,
|
|
53
|
+
message,
|
|
54
|
+
from_email,
|
|
55
|
+
recipient_list,
|
|
56
|
+
fail_silently=False,
|
|
57
|
+
auth_user=None,
|
|
58
|
+
auth_password=None,
|
|
59
|
+
connection=None,
|
|
60
|
+
html_message=None,
|
|
61
|
+
):
|
|
62
|
+
"""
|
|
63
|
+
Easy wrapper for sending a single message to a recipient list. All members
|
|
64
|
+
of the recipient list will see the other recipients in the 'To' field.
|
|
65
|
+
|
|
66
|
+
If from_email is None, use the EMAIL_DEFAULT_FROM setting.
|
|
67
|
+
If auth_user is None, use the EMAIL_HOST_USER setting.
|
|
68
|
+
If auth_password is None, use the EMAIL_HOST_PASSWORD setting.
|
|
69
|
+
|
|
70
|
+
Note: The API for this method is frozen. New code wanting to extend the
|
|
71
|
+
functionality should use the EmailMessage class directly.
|
|
72
|
+
"""
|
|
73
|
+
connection = connection or get_connection(
|
|
74
|
+
username=auth_user,
|
|
75
|
+
password=auth_password,
|
|
76
|
+
fail_silently=fail_silently,
|
|
77
|
+
)
|
|
78
|
+
mail = EmailMultiAlternatives(
|
|
79
|
+
subject, message, from_email, recipient_list, connection=connection
|
|
80
|
+
)
|
|
81
|
+
if html_message:
|
|
82
|
+
mail.attach_alternative(html_message, "text/html")
|
|
83
|
+
|
|
84
|
+
return mail.send()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def send_mass_mail(
|
|
88
|
+
datatuple, fail_silently=False, auth_user=None, auth_password=None, connection=None
|
|
89
|
+
):
|
|
90
|
+
"""
|
|
91
|
+
Given a datatuple of (subject, message, from_email, recipient_list), send
|
|
92
|
+
each message to each recipient list. Return the number of emails sent.
|
|
93
|
+
|
|
94
|
+
If from_email is None, use the EMAIL_DEFAULT_FROM setting.
|
|
95
|
+
If auth_user and auth_password are set, use them to log in.
|
|
96
|
+
If auth_user is None, use the EMAIL_HOST_USER setting.
|
|
97
|
+
If auth_password is None, use the EMAIL_HOST_PASSWORD setting.
|
|
98
|
+
|
|
99
|
+
Note: The API for this method is frozen. New code wanting to extend the
|
|
100
|
+
functionality should use the EmailMessage class directly.
|
|
101
|
+
"""
|
|
102
|
+
connection = connection or get_connection(
|
|
103
|
+
username=auth_user,
|
|
104
|
+
password=auth_password,
|
|
105
|
+
fail_silently=fail_silently,
|
|
106
|
+
)
|
|
107
|
+
messages = [
|
|
108
|
+
EmailMessage(subject, message, sender, recipient, connection=connection)
|
|
109
|
+
for subject, message, sender, recipient in datatuple
|
|
110
|
+
]
|
|
111
|
+
return connection.send_messages(messages)
|
|
File without changes
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Base email backend class."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BaseEmailBackend:
|
|
5
|
+
"""
|
|
6
|
+
Base class for email backend implementations.
|
|
7
|
+
|
|
8
|
+
Subclasses must at least overwrite send_messages().
|
|
9
|
+
|
|
10
|
+
open() and close() can be called indirectly by using a backend object as a
|
|
11
|
+
context manager:
|
|
12
|
+
|
|
13
|
+
with backend as connection:
|
|
14
|
+
# do something with connection
|
|
15
|
+
pass
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, fail_silently=False, **kwargs):
|
|
19
|
+
self.fail_silently = fail_silently
|
|
20
|
+
|
|
21
|
+
def open(self):
|
|
22
|
+
"""
|
|
23
|
+
Open a network connection.
|
|
24
|
+
|
|
25
|
+
This method can be overwritten by backend implementations to
|
|
26
|
+
open a network connection.
|
|
27
|
+
|
|
28
|
+
It's up to the backend implementation to track the status of
|
|
29
|
+
a network connection if it's needed by the backend.
|
|
30
|
+
|
|
31
|
+
This method can be called by applications to force a single
|
|
32
|
+
network connection to be used when sending mails. See the
|
|
33
|
+
send_messages() method of the SMTP backend for a reference
|
|
34
|
+
implementation.
|
|
35
|
+
|
|
36
|
+
The default implementation does nothing.
|
|
37
|
+
"""
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
def close(self):
|
|
41
|
+
"""Close a network connection."""
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
def __enter__(self):
|
|
45
|
+
try:
|
|
46
|
+
self.open()
|
|
47
|
+
except Exception:
|
|
48
|
+
self.close()
|
|
49
|
+
raise
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
53
|
+
self.close()
|
|
54
|
+
|
|
55
|
+
def send_messages(self, email_messages):
|
|
56
|
+
"""
|
|
57
|
+
Send one or more EmailMessage objects and return the number of email
|
|
58
|
+
messages sent.
|
|
59
|
+
"""
|
|
60
|
+
raise NotImplementedError(
|
|
61
|
+
"subclasses of BaseEmailBackend must override send_messages() method"
|
|
62
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Email backend that writes messages to console instead of sending them.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
|
|
8
|
+
from .base import BaseEmailBackend
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EmailBackend(BaseEmailBackend):
|
|
12
|
+
def __init__(self, *args, **kwargs):
|
|
13
|
+
self.stream = kwargs.pop("stream", sys.stdout)
|
|
14
|
+
self._lock = threading.RLock()
|
|
15
|
+
super().__init__(*args, **kwargs)
|
|
16
|
+
|
|
17
|
+
def write_message(self, message):
|
|
18
|
+
msg = message.message()
|
|
19
|
+
msg_data = msg.as_bytes()
|
|
20
|
+
charset = (
|
|
21
|
+
msg.get_charset().get_output_charset() if msg.get_charset() else "utf-8"
|
|
22
|
+
)
|
|
23
|
+
msg_data = msg_data.decode(charset)
|
|
24
|
+
self.stream.write(f"{msg_data}\n")
|
|
25
|
+
self.stream.write("-" * 79)
|
|
26
|
+
self.stream.write("\n")
|
|
27
|
+
|
|
28
|
+
def send_messages(self, email_messages):
|
|
29
|
+
"""Write all messages to the stream in a thread-safe way."""
|
|
30
|
+
if not email_messages:
|
|
31
|
+
return
|
|
32
|
+
msg_count = 0
|
|
33
|
+
with self._lock:
|
|
34
|
+
try:
|
|
35
|
+
stream_created = self.open()
|
|
36
|
+
for message in email_messages:
|
|
37
|
+
self.write_message(message)
|
|
38
|
+
self.stream.flush() # flush after each message
|
|
39
|
+
msg_count += 1
|
|
40
|
+
if stream_created:
|
|
41
|
+
self.close()
|
|
42
|
+
except Exception:
|
|
43
|
+
if not self.fail_silently:
|
|
44
|
+
raise
|
|
45
|
+
return msg_count
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Email backend that writes messages to a file."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from plain.exceptions import ImproperlyConfigured
|
|
7
|
+
from plain.runtime import settings
|
|
8
|
+
|
|
9
|
+
from .console import EmailBackend as ConsoleEmailBackend
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EmailBackend(ConsoleEmailBackend):
|
|
13
|
+
def __init__(self, *args, file_path=None, **kwargs):
|
|
14
|
+
self._fname = None
|
|
15
|
+
if file_path is not None:
|
|
16
|
+
self.file_path = file_path
|
|
17
|
+
else:
|
|
18
|
+
self.file_path = getattr(settings, "EMAIL_FILE_PATH", None)
|
|
19
|
+
self.file_path = os.path.abspath(self.file_path)
|
|
20
|
+
try:
|
|
21
|
+
os.makedirs(self.file_path, exist_ok=True)
|
|
22
|
+
except FileExistsError:
|
|
23
|
+
raise ImproperlyConfigured(
|
|
24
|
+
f"Path for saving email messages exists, but is not a directory: {self.file_path}"
|
|
25
|
+
)
|
|
26
|
+
except OSError as err:
|
|
27
|
+
raise ImproperlyConfigured(
|
|
28
|
+
f"Could not create directory for saving email messages: {self.file_path} ({err})"
|
|
29
|
+
)
|
|
30
|
+
# Make sure that self.file_path is writable.
|
|
31
|
+
if not os.access(self.file_path, os.W_OK):
|
|
32
|
+
raise ImproperlyConfigured(
|
|
33
|
+
f"Could not write to directory: {self.file_path}"
|
|
34
|
+
)
|
|
35
|
+
# Finally, call super().
|
|
36
|
+
# Since we're using the console-based backend as a base,
|
|
37
|
+
# force the stream to be None, so we don't default to stdout
|
|
38
|
+
kwargs["stream"] = None
|
|
39
|
+
super().__init__(*args, **kwargs)
|
|
40
|
+
|
|
41
|
+
def write_message(self, message):
|
|
42
|
+
self.stream.write(message.message().as_bytes() + b"\n")
|
|
43
|
+
self.stream.write(b"-" * 79)
|
|
44
|
+
self.stream.write(b"\n")
|
|
45
|
+
|
|
46
|
+
def _get_filename(self):
|
|
47
|
+
"""Return a unique file name."""
|
|
48
|
+
if self._fname is None:
|
|
49
|
+
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
50
|
+
fname = f"{timestamp}-{abs(id(self))}.log"
|
|
51
|
+
self._fname = os.path.join(self.file_path, fname)
|
|
52
|
+
return self._fname
|
|
53
|
+
|
|
54
|
+
def open(self):
|
|
55
|
+
if self.stream is None:
|
|
56
|
+
self.stream = open(self._get_filename(), "ab")
|
|
57
|
+
return True
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
def close(self):
|
|
61
|
+
try:
|
|
62
|
+
if self.stream is not None:
|
|
63
|
+
self.stream.close()
|
|
64
|
+
finally:
|
|
65
|
+
self.stream = None
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""SMTP email backend class."""
|
|
2
|
+
|
|
3
|
+
import smtplib
|
|
4
|
+
import ssl
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
from plain.runtime import settings
|
|
8
|
+
from plain.utils.functional import cached_property
|
|
9
|
+
|
|
10
|
+
from ..backends.base import BaseEmailBackend
|
|
11
|
+
from ..message import sanitize_address
|
|
12
|
+
from ..utils import DNS_NAME
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class 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
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# The email backend to use. For possible shortcuts see plain.email.
|
|
2
|
+
# The default is to use the SMTP backend.
|
|
3
|
+
# Third-party backends can be specified by providing a Python path
|
|
4
|
+
# to a module that defines an EmailBackend class.
|
|
5
|
+
EMAIL_BACKEND: str
|
|
6
|
+
|
|
7
|
+
EMAIL_DEFAULT_FROM: str
|
|
8
|
+
|
|
9
|
+
EMAIL_DEFAULT_REPLY_TO: list[str] = None
|
|
10
|
+
|
|
11
|
+
# Host for sending email.
|
|
12
|
+
EMAIL_HOST: str = "localhost"
|
|
13
|
+
|
|
14
|
+
# Port for sending email.
|
|
15
|
+
EMAIL_PORT: int = 25
|
|
16
|
+
|
|
17
|
+
# Whether to send SMTP 'Date' header in the local time zone or in UTC.
|
|
18
|
+
EMAIL_USE_LOCALTIME: bool = False
|
|
19
|
+
|
|
20
|
+
# Optional SMTP authentication information for EMAIL_HOST.
|
|
21
|
+
EMAIL_HOST_USER: str = ""
|
|
22
|
+
EMAIL_HOST_PASSWORD: str = ""
|
|
23
|
+
EMAIL_USE_TLS: bool = False
|
|
24
|
+
EMAIL_USE_SSL: bool = False
|
|
25
|
+
EMAIL_SSL_CERTFILE: str = None
|
|
26
|
+
EMAIL_SSL_KEYFILE: str = None
|
|
27
|
+
EMAIL_TIMEOUT: int = None
|
plain/email/message.py
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import mimetypes
|
|
2
|
+
from email import charset as Charset
|
|
3
|
+
from email import encoders as Encoders
|
|
4
|
+
from email import generator, message_from_string
|
|
5
|
+
from email.errors import HeaderParseError
|
|
6
|
+
from email.header import Header
|
|
7
|
+
from email.headerregistry import Address, parser
|
|
8
|
+
from email.message import Message
|
|
9
|
+
from email.mime.base import MIMEBase
|
|
10
|
+
from email.mime.message import MIMEMessage
|
|
11
|
+
from email.mime.multipart import MIMEMultipart
|
|
12
|
+
from email.mime.text import MIMEText
|
|
13
|
+
from email.utils import formataddr, formatdate, getaddresses, make_msgid
|
|
14
|
+
from io import BytesIO, StringIO
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from plain.runtime import settings
|
|
18
|
+
from plain.templates import Template, TemplateFileMissing
|
|
19
|
+
from plain.utils.encoding import force_str, punycode
|
|
20
|
+
from plain.utils.html import strip_tags
|
|
21
|
+
|
|
22
|
+
from .utils import DNS_NAME
|
|
23
|
+
|
|
24
|
+
# Don't BASE64-encode UTF-8 messages so that we avoid unwanted attention from
|
|
25
|
+
# some spam filters.
|
|
26
|
+
utf8_charset = Charset.Charset("utf-8")
|
|
27
|
+
utf8_charset.body_encoding = None # Python defaults to BASE64
|
|
28
|
+
utf8_charset_qp = Charset.Charset("utf-8")
|
|
29
|
+
utf8_charset_qp.body_encoding = Charset.QP
|
|
30
|
+
|
|
31
|
+
# Default MIME type to use on attachments (if it is not explicitly given
|
|
32
|
+
# and cannot be guessed).
|
|
33
|
+
DEFAULT_ATTACHMENT_MIME_TYPE = "application/octet-stream"
|
|
34
|
+
|
|
35
|
+
RFC5322_EMAIL_LINE_LENGTH_LIMIT = 998
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BadHeaderError(ValueError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Header names that contain structured address data (RFC 5322).
|
|
43
|
+
ADDRESS_HEADERS = {
|
|
44
|
+
"from",
|
|
45
|
+
"sender",
|
|
46
|
+
"reply-to",
|
|
47
|
+
"to",
|
|
48
|
+
"cc",
|
|
49
|
+
"bcc",
|
|
50
|
+
"resent-from",
|
|
51
|
+
"resent-sender",
|
|
52
|
+
"resent-to",
|
|
53
|
+
"resent-cc",
|
|
54
|
+
"resent-bcc",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def forbid_multi_line_headers(name, val, encoding):
|
|
59
|
+
"""Forbid multi-line headers to prevent header injection."""
|
|
60
|
+
encoding = encoding or settings.DEFAULT_CHARSET
|
|
61
|
+
val = str(val) # val may be lazy
|
|
62
|
+
if "\n" in val or "\r" in val:
|
|
63
|
+
raise BadHeaderError(
|
|
64
|
+
f"Header values can't contain newlines (got {val!r} for header {name!r})"
|
|
65
|
+
)
|
|
66
|
+
try:
|
|
67
|
+
val.encode("ascii")
|
|
68
|
+
except UnicodeEncodeError:
|
|
69
|
+
if name.lower() in ADDRESS_HEADERS:
|
|
70
|
+
val = ", ".join(
|
|
71
|
+
sanitize_address(addr, encoding) for addr in getaddresses((val,))
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
val = Header(val, encoding).encode()
|
|
75
|
+
else:
|
|
76
|
+
if name.lower() == "subject":
|
|
77
|
+
val = Header(val).encode()
|
|
78
|
+
return name, val
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def sanitize_address(addr, encoding):
|
|
82
|
+
"""
|
|
83
|
+
Format a pair of (name, address) or an email address string.
|
|
84
|
+
"""
|
|
85
|
+
address = None
|
|
86
|
+
if not isinstance(addr, tuple):
|
|
87
|
+
addr = force_str(addr)
|
|
88
|
+
try:
|
|
89
|
+
token, rest = parser.get_mailbox(addr)
|
|
90
|
+
except (HeaderParseError, ValueError, IndexError):
|
|
91
|
+
raise ValueError(f'Invalid address "{addr}"')
|
|
92
|
+
else:
|
|
93
|
+
if rest:
|
|
94
|
+
# The entire email address must be parsed.
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f'Invalid address; only {token} could be parsed from "{addr}"'
|
|
97
|
+
)
|
|
98
|
+
nm = token.display_name or ""
|
|
99
|
+
localpart = token.local_part
|
|
100
|
+
domain = token.domain or ""
|
|
101
|
+
else:
|
|
102
|
+
nm, address = addr
|
|
103
|
+
localpart, domain = address.rsplit("@", 1)
|
|
104
|
+
|
|
105
|
+
address_parts = nm + localpart + domain
|
|
106
|
+
if "\n" in address_parts or "\r" in address_parts:
|
|
107
|
+
raise ValueError("Invalid address; address parts cannot contain newlines.")
|
|
108
|
+
|
|
109
|
+
# Avoid UTF-8 encode, if it's possible.
|
|
110
|
+
try:
|
|
111
|
+
nm.encode("ascii")
|
|
112
|
+
nm = Header(nm).encode()
|
|
113
|
+
except UnicodeEncodeError:
|
|
114
|
+
nm = Header(nm, encoding).encode()
|
|
115
|
+
try:
|
|
116
|
+
localpart.encode("ascii")
|
|
117
|
+
except UnicodeEncodeError:
|
|
118
|
+
localpart = Header(localpart, encoding).encode()
|
|
119
|
+
domain = punycode(domain)
|
|
120
|
+
|
|
121
|
+
parsed_address = Address(username=localpart, domain=domain)
|
|
122
|
+
return formataddr((nm, parsed_address.addr_spec))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class MIMEMixin:
|
|
126
|
+
def as_string(self, unixfrom=False, linesep="\n"):
|
|
127
|
+
"""Return the entire formatted message as a string.
|
|
128
|
+
Optional `unixfrom' when True, means include the Unix From_ envelope
|
|
129
|
+
header.
|
|
130
|
+
|
|
131
|
+
This overrides the default as_string() implementation to not mangle
|
|
132
|
+
lines that begin with 'From '. See bug #13433 for details.
|
|
133
|
+
"""
|
|
134
|
+
fp = StringIO()
|
|
135
|
+
g = generator.Generator(fp, mangle_from_=False)
|
|
136
|
+
g.flatten(self, unixfrom=unixfrom, linesep=linesep)
|
|
137
|
+
return fp.getvalue()
|
|
138
|
+
|
|
139
|
+
def as_bytes(self, unixfrom=False, linesep="\n"):
|
|
140
|
+
"""Return the entire formatted message as bytes.
|
|
141
|
+
Optional `unixfrom' when True, means include the Unix From_ envelope
|
|
142
|
+
header.
|
|
143
|
+
|
|
144
|
+
This overrides the default as_bytes() implementation to not mangle
|
|
145
|
+
lines that begin with 'From '. See bug #13433 for details.
|
|
146
|
+
"""
|
|
147
|
+
fp = BytesIO()
|
|
148
|
+
g = generator.BytesGenerator(fp, mangle_from_=False)
|
|
149
|
+
g.flatten(self, unixfrom=unixfrom, linesep=linesep)
|
|
150
|
+
return fp.getvalue()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class SafeMIMEMessage(MIMEMixin, MIMEMessage):
|
|
154
|
+
def __setitem__(self, name, val):
|
|
155
|
+
# message/rfc822 attachments must be ASCII
|
|
156
|
+
name, val = forbid_multi_line_headers(name, val, "ascii")
|
|
157
|
+
MIMEMessage.__setitem__(self, name, val)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class SafeMIMEText(MIMEMixin, MIMEText):
|
|
161
|
+
def __init__(self, _text, _subtype="plain", _charset=None):
|
|
162
|
+
self.encoding = _charset
|
|
163
|
+
MIMEText.__init__(self, _text, _subtype=_subtype, _charset=_charset)
|
|
164
|
+
|
|
165
|
+
def __setitem__(self, name, val):
|
|
166
|
+
name, val = forbid_multi_line_headers(name, val, self.encoding)
|
|
167
|
+
MIMEText.__setitem__(self, name, val)
|
|
168
|
+
|
|
169
|
+
def set_payload(self, payload, charset=None):
|
|
170
|
+
if charset == "utf-8" and not isinstance(charset, Charset.Charset):
|
|
171
|
+
has_long_lines = any(
|
|
172
|
+
len(line.encode()) > RFC5322_EMAIL_LINE_LENGTH_LIMIT
|
|
173
|
+
for line in payload.splitlines()
|
|
174
|
+
)
|
|
175
|
+
# Quoted-Printable encoding has the side effect of shortening long
|
|
176
|
+
# lines, if any (#22561).
|
|
177
|
+
charset = utf8_charset_qp if has_long_lines else utf8_charset
|
|
178
|
+
MIMEText.set_payload(self, payload, charset=charset)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class SafeMIMEMultipart(MIMEMixin, MIMEMultipart):
|
|
182
|
+
def __init__(
|
|
183
|
+
self, _subtype="mixed", boundary=None, _subparts=None, encoding=None, **_params
|
|
184
|
+
):
|
|
185
|
+
self.encoding = encoding
|
|
186
|
+
MIMEMultipart.__init__(self, _subtype, boundary, _subparts, **_params)
|
|
187
|
+
|
|
188
|
+
def __setitem__(self, name, val):
|
|
189
|
+
name, val = forbid_multi_line_headers(name, val, self.encoding)
|
|
190
|
+
MIMEMultipart.__setitem__(self, name, val)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class EmailMessage:
|
|
194
|
+
"""A container for email information."""
|
|
195
|
+
|
|
196
|
+
content_subtype = "plain"
|
|
197
|
+
mixed_subtype = "mixed"
|
|
198
|
+
encoding = None # None => use settings default
|
|
199
|
+
|
|
200
|
+
def __init__(
|
|
201
|
+
self,
|
|
202
|
+
subject="",
|
|
203
|
+
body="",
|
|
204
|
+
from_email=None,
|
|
205
|
+
to=None,
|
|
206
|
+
bcc=None,
|
|
207
|
+
connection=None,
|
|
208
|
+
attachments=None,
|
|
209
|
+
headers=None,
|
|
210
|
+
cc=None,
|
|
211
|
+
reply_to=None,
|
|
212
|
+
):
|
|
213
|
+
"""
|
|
214
|
+
Initialize a single email message (which can be sent to multiple
|
|
215
|
+
recipients).
|
|
216
|
+
"""
|
|
217
|
+
if to:
|
|
218
|
+
if isinstance(to, str):
|
|
219
|
+
raise TypeError('"to" argument must be a list or tuple')
|
|
220
|
+
self.to = list(to)
|
|
221
|
+
else:
|
|
222
|
+
self.to = []
|
|
223
|
+
if cc:
|
|
224
|
+
if isinstance(cc, str):
|
|
225
|
+
raise TypeError('"cc" argument must be a list or tuple')
|
|
226
|
+
self.cc = list(cc)
|
|
227
|
+
else:
|
|
228
|
+
self.cc = []
|
|
229
|
+
if bcc:
|
|
230
|
+
if isinstance(bcc, str):
|
|
231
|
+
raise TypeError('"bcc" argument must be a list or tuple')
|
|
232
|
+
self.bcc = list(bcc)
|
|
233
|
+
else:
|
|
234
|
+
self.bcc = []
|
|
235
|
+
if reply_to:
|
|
236
|
+
if isinstance(reply_to, str):
|
|
237
|
+
raise TypeError('"reply_to" argument must be a list or tuple')
|
|
238
|
+
self.reply_to = list(reply_to)
|
|
239
|
+
else:
|
|
240
|
+
self.reply_to = settings.EMAIL_DEFAULT_REPLY_TO or []
|
|
241
|
+
self.from_email = from_email or settings.EMAIL_DEFAULT_FROM
|
|
242
|
+
self.subject = subject
|
|
243
|
+
self.body = body or ""
|
|
244
|
+
self.attachments = []
|
|
245
|
+
if attachments:
|
|
246
|
+
for attachment in attachments:
|
|
247
|
+
if isinstance(attachment, MIMEBase):
|
|
248
|
+
self.attach(attachment)
|
|
249
|
+
else:
|
|
250
|
+
self.attach(*attachment)
|
|
251
|
+
self.extra_headers = headers or {}
|
|
252
|
+
self.connection = connection
|
|
253
|
+
|
|
254
|
+
def get_connection(self, fail_silently=False):
|
|
255
|
+
from . import get_connection
|
|
256
|
+
|
|
257
|
+
if not self.connection:
|
|
258
|
+
self.connection = get_connection(fail_silently=fail_silently)
|
|
259
|
+
return self.connection
|
|
260
|
+
|
|
261
|
+
def message(self):
|
|
262
|
+
encoding = self.encoding or settings.DEFAULT_CHARSET
|
|
263
|
+
msg = SafeMIMEText(self.body, self.content_subtype, encoding)
|
|
264
|
+
msg = self._create_message(msg)
|
|
265
|
+
msg["Subject"] = self.subject
|
|
266
|
+
msg["From"] = self.extra_headers.get("From", self.from_email)
|
|
267
|
+
self._set_list_header_if_not_empty(msg, "To", self.to)
|
|
268
|
+
self._set_list_header_if_not_empty(msg, "Cc", self.cc)
|
|
269
|
+
self._set_list_header_if_not_empty(msg, "Reply-To", self.reply_to)
|
|
270
|
+
|
|
271
|
+
# Email header names are case-insensitive (RFC 2045), so we have to
|
|
272
|
+
# accommodate that when doing comparisons.
|
|
273
|
+
header_names = [key.lower() for key in self.extra_headers]
|
|
274
|
+
if "date" not in header_names:
|
|
275
|
+
# formatdate() uses stdlib methods to format the date, which use
|
|
276
|
+
# the stdlib/OS concept of a timezone, however, Plain sets the
|
|
277
|
+
# TZ environment variable based on the TIME_ZONE setting which
|
|
278
|
+
# will get picked up by formatdate().
|
|
279
|
+
msg["Date"] = formatdate(localtime=settings.EMAIL_USE_LOCALTIME)
|
|
280
|
+
if "message-id" not in header_names:
|
|
281
|
+
# Use cached DNS_NAME for performance
|
|
282
|
+
msg["Message-ID"] = make_msgid(domain=DNS_NAME)
|
|
283
|
+
for name, value in self.extra_headers.items():
|
|
284
|
+
if name.lower() != "from": # From is already handled
|
|
285
|
+
msg[name] = value
|
|
286
|
+
return msg
|
|
287
|
+
|
|
288
|
+
def recipients(self):
|
|
289
|
+
"""
|
|
290
|
+
Return a list of all recipients of the email (includes direct
|
|
291
|
+
addressees as well as Cc and Bcc entries).
|
|
292
|
+
"""
|
|
293
|
+
return [email for email in (self.to + self.cc + self.bcc) if email]
|
|
294
|
+
|
|
295
|
+
def send(self, fail_silently=False):
|
|
296
|
+
"""Send the email message."""
|
|
297
|
+
if not self.recipients():
|
|
298
|
+
# Don't bother creating the network connection if there's nobody to
|
|
299
|
+
# send to.
|
|
300
|
+
return 0
|
|
301
|
+
return self.get_connection(fail_silently).send_messages([self])
|
|
302
|
+
|
|
303
|
+
def attach(self, filename=None, content=None, mimetype=None):
|
|
304
|
+
"""
|
|
305
|
+
Attach a file with the given filename and content. The filename can
|
|
306
|
+
be omitted and the mimetype is guessed, if not provided.
|
|
307
|
+
|
|
308
|
+
If the first parameter is a MIMEBase subclass, insert it directly
|
|
309
|
+
into the resulting message attachments.
|
|
310
|
+
|
|
311
|
+
For a text/* mimetype (guessed or specified), when a bytes object is
|
|
312
|
+
specified as content, decode it as UTF-8. If that fails, set the
|
|
313
|
+
mimetype to DEFAULT_ATTACHMENT_MIME_TYPE and don't decode the content.
|
|
314
|
+
"""
|
|
315
|
+
if isinstance(filename, MIMEBase):
|
|
316
|
+
if content is not None or mimetype is not None:
|
|
317
|
+
raise ValueError(
|
|
318
|
+
"content and mimetype must not be given when a MIMEBase "
|
|
319
|
+
"instance is provided."
|
|
320
|
+
)
|
|
321
|
+
self.attachments.append(filename)
|
|
322
|
+
elif content is None:
|
|
323
|
+
raise ValueError("content must be provided.")
|
|
324
|
+
else:
|
|
325
|
+
mimetype = (
|
|
326
|
+
mimetype
|
|
327
|
+
or mimetypes.guess_type(filename)[0]
|
|
328
|
+
or DEFAULT_ATTACHMENT_MIME_TYPE
|
|
329
|
+
)
|
|
330
|
+
basetype, subtype = mimetype.split("/", 1)
|
|
331
|
+
|
|
332
|
+
if basetype == "text":
|
|
333
|
+
if isinstance(content, bytes):
|
|
334
|
+
try:
|
|
335
|
+
content = content.decode()
|
|
336
|
+
except UnicodeDecodeError:
|
|
337
|
+
# If mimetype suggests the file is text but it's
|
|
338
|
+
# actually binary, read() raises a UnicodeDecodeError.
|
|
339
|
+
mimetype = DEFAULT_ATTACHMENT_MIME_TYPE
|
|
340
|
+
|
|
341
|
+
self.attachments.append((filename, content, mimetype))
|
|
342
|
+
|
|
343
|
+
def attach_file(self, path, mimetype=None):
|
|
344
|
+
"""
|
|
345
|
+
Attach a file from the filesystem.
|
|
346
|
+
|
|
347
|
+
Set the mimetype to DEFAULT_ATTACHMENT_MIME_TYPE if it isn't specified
|
|
348
|
+
and cannot be guessed.
|
|
349
|
+
|
|
350
|
+
For a text/* mimetype (guessed or specified), decode the file's content
|
|
351
|
+
as UTF-8. If that fails, set the mimetype to
|
|
352
|
+
DEFAULT_ATTACHMENT_MIME_TYPE and don't decode the content.
|
|
353
|
+
"""
|
|
354
|
+
path = Path(path)
|
|
355
|
+
with path.open("rb") as file:
|
|
356
|
+
content = file.read()
|
|
357
|
+
self.attach(path.name, content, mimetype)
|
|
358
|
+
|
|
359
|
+
def _create_message(self, msg):
|
|
360
|
+
return self._create_attachments(msg)
|
|
361
|
+
|
|
362
|
+
def _create_attachments(self, msg):
|
|
363
|
+
if self.attachments:
|
|
364
|
+
encoding = self.encoding or settings.DEFAULT_CHARSET
|
|
365
|
+
body_msg = msg
|
|
366
|
+
msg = SafeMIMEMultipart(_subtype=self.mixed_subtype, encoding=encoding)
|
|
367
|
+
if self.body or body_msg.is_multipart():
|
|
368
|
+
msg.attach(body_msg)
|
|
369
|
+
for attachment in self.attachments:
|
|
370
|
+
if isinstance(attachment, MIMEBase):
|
|
371
|
+
msg.attach(attachment)
|
|
372
|
+
else:
|
|
373
|
+
msg.attach(self._create_attachment(*attachment))
|
|
374
|
+
return msg
|
|
375
|
+
|
|
376
|
+
def _create_mime_attachment(self, content, mimetype):
|
|
377
|
+
"""
|
|
378
|
+
Convert the content, mimetype pair into a MIME attachment object.
|
|
379
|
+
|
|
380
|
+
If the mimetype is message/rfc822, content may be an
|
|
381
|
+
email.Message or EmailMessage object, as well as a str.
|
|
382
|
+
"""
|
|
383
|
+
basetype, subtype = mimetype.split("/", 1)
|
|
384
|
+
if basetype == "text":
|
|
385
|
+
encoding = self.encoding or settings.DEFAULT_CHARSET
|
|
386
|
+
attachment = SafeMIMEText(content, subtype, encoding)
|
|
387
|
+
elif basetype == "message" and subtype == "rfc822":
|
|
388
|
+
# Bug #18967: Per RFC 2046 Section 5.2.1, message/rfc822
|
|
389
|
+
# attachments must not be base64 encoded.
|
|
390
|
+
if isinstance(content, EmailMessage):
|
|
391
|
+
# convert content into an email.Message first
|
|
392
|
+
content = content.message()
|
|
393
|
+
elif not isinstance(content, Message):
|
|
394
|
+
# For compatibility with existing code, parse the message
|
|
395
|
+
# into an email.Message object if it is not one already.
|
|
396
|
+
content = message_from_string(force_str(content))
|
|
397
|
+
|
|
398
|
+
attachment = SafeMIMEMessage(content, subtype)
|
|
399
|
+
else:
|
|
400
|
+
# Encode non-text attachments with base64.
|
|
401
|
+
attachment = MIMEBase(basetype, subtype)
|
|
402
|
+
attachment.set_payload(content)
|
|
403
|
+
Encoders.encode_base64(attachment)
|
|
404
|
+
return attachment
|
|
405
|
+
|
|
406
|
+
def _create_attachment(self, filename, content, mimetype=None):
|
|
407
|
+
"""
|
|
408
|
+
Convert the filename, content, mimetype triple into a MIME attachment
|
|
409
|
+
object.
|
|
410
|
+
"""
|
|
411
|
+
attachment = self._create_mime_attachment(content, mimetype)
|
|
412
|
+
if filename:
|
|
413
|
+
try:
|
|
414
|
+
filename.encode("ascii")
|
|
415
|
+
except UnicodeEncodeError:
|
|
416
|
+
filename = ("utf-8", "", filename)
|
|
417
|
+
attachment.add_header(
|
|
418
|
+
"Content-Disposition", "attachment", filename=filename
|
|
419
|
+
)
|
|
420
|
+
return attachment
|
|
421
|
+
|
|
422
|
+
def _set_list_header_if_not_empty(self, msg, header, values):
|
|
423
|
+
"""
|
|
424
|
+
Set msg's header, either from self.extra_headers, if present, or from
|
|
425
|
+
the values argument.
|
|
426
|
+
"""
|
|
427
|
+
if values:
|
|
428
|
+
try:
|
|
429
|
+
value = self.extra_headers[header]
|
|
430
|
+
except KeyError:
|
|
431
|
+
value = ", ".join(str(v) for v in values)
|
|
432
|
+
msg[header] = value
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
class EmailMultiAlternatives(EmailMessage):
|
|
436
|
+
"""
|
|
437
|
+
A version of EmailMessage that makes it easy to send multipart/alternative
|
|
438
|
+
messages. For example, including text and HTML versions of the text is
|
|
439
|
+
made easier.
|
|
440
|
+
"""
|
|
441
|
+
|
|
442
|
+
alternative_subtype = "alternative"
|
|
443
|
+
|
|
444
|
+
def __init__(
|
|
445
|
+
self,
|
|
446
|
+
subject="",
|
|
447
|
+
body="",
|
|
448
|
+
from_email=None,
|
|
449
|
+
to=None,
|
|
450
|
+
bcc=None,
|
|
451
|
+
connection=None,
|
|
452
|
+
attachments=None,
|
|
453
|
+
headers=None,
|
|
454
|
+
alternatives=None,
|
|
455
|
+
cc=None,
|
|
456
|
+
reply_to=None,
|
|
457
|
+
):
|
|
458
|
+
"""
|
|
459
|
+
Initialize a single email message (which can be sent to multiple
|
|
460
|
+
recipients).
|
|
461
|
+
"""
|
|
462
|
+
super().__init__(
|
|
463
|
+
subject,
|
|
464
|
+
body,
|
|
465
|
+
from_email,
|
|
466
|
+
to,
|
|
467
|
+
bcc,
|
|
468
|
+
connection,
|
|
469
|
+
attachments,
|
|
470
|
+
headers,
|
|
471
|
+
cc,
|
|
472
|
+
reply_to,
|
|
473
|
+
)
|
|
474
|
+
self.alternatives = alternatives or []
|
|
475
|
+
|
|
476
|
+
def attach_alternative(self, content, mimetype):
|
|
477
|
+
"""Attach an alternative content representation."""
|
|
478
|
+
if content is None or mimetype is None:
|
|
479
|
+
raise ValueError("Both content and mimetype must be provided.")
|
|
480
|
+
self.alternatives.append((content, mimetype))
|
|
481
|
+
|
|
482
|
+
def _create_message(self, msg):
|
|
483
|
+
return self._create_attachments(self._create_alternatives(msg))
|
|
484
|
+
|
|
485
|
+
def _create_alternatives(self, msg):
|
|
486
|
+
encoding = self.encoding or settings.DEFAULT_CHARSET
|
|
487
|
+
if self.alternatives:
|
|
488
|
+
body_msg = msg
|
|
489
|
+
msg = SafeMIMEMultipart(
|
|
490
|
+
_subtype=self.alternative_subtype, encoding=encoding
|
|
491
|
+
)
|
|
492
|
+
if self.body:
|
|
493
|
+
msg.attach(body_msg)
|
|
494
|
+
for alternative in self.alternatives:
|
|
495
|
+
msg.attach(self._create_mime_attachment(*alternative))
|
|
496
|
+
return msg
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
class TemplateEmail(EmailMultiAlternatives):
|
|
500
|
+
def __init__(
|
|
501
|
+
self,
|
|
502
|
+
*,
|
|
503
|
+
template,
|
|
504
|
+
context=None,
|
|
505
|
+
subject="",
|
|
506
|
+
from_email=None,
|
|
507
|
+
to=None,
|
|
508
|
+
bcc=None,
|
|
509
|
+
connection=None,
|
|
510
|
+
attachments=None,
|
|
511
|
+
headers=None,
|
|
512
|
+
alternatives=None,
|
|
513
|
+
cc=None,
|
|
514
|
+
reply_to=None,
|
|
515
|
+
):
|
|
516
|
+
self.template = template
|
|
517
|
+
self.context = context or {}
|
|
518
|
+
|
|
519
|
+
# Run this once for all uses of the context
|
|
520
|
+
render_context = self.get_template_context()
|
|
521
|
+
|
|
522
|
+
self.body_html, body = self.render_content(render_context)
|
|
523
|
+
|
|
524
|
+
if not subject:
|
|
525
|
+
subject = self.render_subject(render_context)
|
|
526
|
+
|
|
527
|
+
super().__init__(
|
|
528
|
+
subject=subject,
|
|
529
|
+
body=body,
|
|
530
|
+
from_email=from_email,
|
|
531
|
+
to=to,
|
|
532
|
+
bcc=bcc,
|
|
533
|
+
connection=connection,
|
|
534
|
+
attachments=attachments,
|
|
535
|
+
headers=headers,
|
|
536
|
+
alternatives=alternatives,
|
|
537
|
+
cc=cc,
|
|
538
|
+
reply_to=reply_to,
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
self.attach_alternative(self.body_html, "text/html")
|
|
542
|
+
|
|
543
|
+
def get_template_context(self):
|
|
544
|
+
"""Subclasses can override this method to add context data."""
|
|
545
|
+
return self.context
|
|
546
|
+
|
|
547
|
+
def render_content(self, context):
|
|
548
|
+
html_content = self.render_html(context)
|
|
549
|
+
|
|
550
|
+
try:
|
|
551
|
+
plain_content = self.render_plain(context)
|
|
552
|
+
except TemplateFileMissing:
|
|
553
|
+
plain_content = strip_tags(html_content)
|
|
554
|
+
|
|
555
|
+
return html_content, plain_content
|
|
556
|
+
|
|
557
|
+
def render_plain(self, context):
|
|
558
|
+
return Template(self.get_plain_template_name()).render(context)
|
|
559
|
+
|
|
560
|
+
def render_html(self, context):
|
|
561
|
+
return Template(self.get_html_template_name()).render(context)
|
|
562
|
+
|
|
563
|
+
def render_subject(self, context):
|
|
564
|
+
try:
|
|
565
|
+
subject = Template(self.get_subject_template_name()).render(context)
|
|
566
|
+
return subject.strip()
|
|
567
|
+
except TemplateFileMissing:
|
|
568
|
+
return ""
|
|
569
|
+
|
|
570
|
+
def get_plain_template_name(self):
|
|
571
|
+
return f"mail/{self.template}.txt"
|
|
572
|
+
|
|
573
|
+
def get_html_template_name(self):
|
|
574
|
+
return f"mail/{self.template}.html"
|
|
575
|
+
|
|
576
|
+
def get_subject_template_name(self):
|
|
577
|
+
return f"mail/{self.template}.subject.txt"
|
plain/email/utils.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Email message and email sending related helper functions.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import socket
|
|
6
|
+
|
|
7
|
+
from plain.utils.encoding import punycode
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Cache the hostname, but do it lazily: socket.getfqdn() can take a couple of
|
|
11
|
+
# seconds, which slows down the restart of the server.
|
|
12
|
+
class CachedDnsName:
|
|
13
|
+
def __str__(self):
|
|
14
|
+
return self.get_fqdn()
|
|
15
|
+
|
|
16
|
+
def get_fqdn(self):
|
|
17
|
+
if not hasattr(self, "_fqdn"):
|
|
18
|
+
self._fqdn = punycode(socket.getfqdn())
|
|
19
|
+
return self._fqdn
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
DNS_NAME = CachedDnsName()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: plain.email
|
|
3
|
+
Version: 0.8.0
|
|
4
|
+
Summary: Email sending for Plain.
|
|
5
|
+
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: plain<1.0.0
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# plain.email
|
|
13
|
+
|
|
14
|
+
Everything you need to send email.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
Add `plain.email` to your `INSTALLED_APPS`:
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
# settings.py
|
|
22
|
+
INSTALLED_APPS = [
|
|
23
|
+
# ...
|
|
24
|
+
'plain.email',
|
|
25
|
+
]
|
|
26
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
plain/email/README.md,sha256=6aI9gU-ucBmxOwarCCsS8EJYfRVZlKtkYCBm7UwodJc,191
|
|
2
|
+
plain/email/__init__.py,sha256=rxQVZrXePn7K0iS7-AEWJKtHHIT2AWTe62HBWCUdX2M,3247
|
|
3
|
+
plain/email/default_settings.py,sha256=HCWhoMXJ7QozRX3YbNbwxV_n7yyfWjKqZFCSEOYR1oc,783
|
|
4
|
+
plain/email/message.py,sha256=OIhgNpQ1TFrtz5fMPnnnZZKGrClXhCr30dP3JwUkq_A,20041
|
|
5
|
+
plain/email/utils.py,sha256=ez-G2FUqRtozr-pXL6k0pceSqyPITilqiNc4hm3e45E,505
|
|
6
|
+
plain/email/backends/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
plain/email/backends/base.py,sha256=Cljbb7nil40Dfpob2R8iLmlO0Yv_wlOCBA9hF2Z6W54,1683
|
|
8
|
+
plain/email/backends/console.py,sha256=QhB_GBNIct7gwoTnVQNYqdLSRB1Hd8vl0U1eZuL_kMM,1400
|
|
9
|
+
plain/email/backends/filebased.py,sha256=uYgDWM5Hyl4GpMQhAjE5yKkxshQ8WgJWZ0ZZDK8mQoI,2277
|
|
10
|
+
plain/email/backends/smtp.py,sha256=oE_PX8MYf78a7j3zj_3SKZs8ZGQ9OQ-B9RHps7KZpQ0,5760
|
|
11
|
+
plain_email-0.8.0.dist-info/METADATA,sha256=s11ZaKJMOJs60wF-Fl27WapTa9v42yV2h9sJfebhKw8,484
|
|
12
|
+
plain_email-0.8.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
13
|
+
plain_email-0.8.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
|
|
14
|
+
plain_email-0.8.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
## Plain is released under the BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
BSD 3-Clause License
|
|
4
|
+
|
|
5
|
+
Copyright (c) 2023, Dropseed, LLC
|
|
6
|
+
|
|
7
|
+
Redistribution and use in source and binary forms, with or without
|
|
8
|
+
modification, are permitted provided that the following conditions are met:
|
|
9
|
+
|
|
10
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
11
|
+
list of conditions and the following disclaimer.
|
|
12
|
+
|
|
13
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
14
|
+
this list of conditions and the following disclaimer in the documentation
|
|
15
|
+
and/or other materials provided with the distribution.
|
|
16
|
+
|
|
17
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
18
|
+
contributors may be used to endorse or promote products derived from
|
|
19
|
+
this software without specific prior written permission.
|
|
20
|
+
|
|
21
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
22
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
23
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
24
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
25
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
26
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
27
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
28
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
29
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
30
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
## This package contains code forked from github.com/django/django
|
|
34
|
+
|
|
35
|
+
Copyright (c) Django Software Foundation and individual contributors.
|
|
36
|
+
All rights reserved.
|
|
37
|
+
|
|
38
|
+
Redistribution and use in source and binary forms, with or without modification,
|
|
39
|
+
are permitted provided that the following conditions are met:
|
|
40
|
+
|
|
41
|
+
1. Redistributions of source code must retain the above copyright notice,
|
|
42
|
+
this list of conditions and the following disclaimer.
|
|
43
|
+
|
|
44
|
+
2. Redistributions in binary form must reproduce the above copyright
|
|
45
|
+
notice, this list of conditions and the following disclaimer in the
|
|
46
|
+
documentation and/or other materials provided with the distribution.
|
|
47
|
+
|
|
48
|
+
3. Neither the name of Django nor the names of its contributors may be used
|
|
49
|
+
to endorse or promote products derived from this software without
|
|
50
|
+
specific prior written permission.
|
|
51
|
+
|
|
52
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
53
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
54
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
55
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
|
56
|
+
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
57
|
+
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
58
|
+
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
|
59
|
+
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
60
|
+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
61
|
+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|