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 ADDED
@@ -0,0 +1,15 @@
1
+ # plain.email
2
+
3
+ Everything you need to send email.
4
+
5
+ ## Installation
6
+
7
+ Add `plain.email` to your `INSTALLED_APPS`:
8
+
9
+ ```python
10
+ # settings.py
11
+ INSTALLED_APPS = [
12
+ # ...
13
+ 'plain.email',
14
+ ]
15
+ ```
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.