plain.email 0.10.2__py3-none-any.whl → 0.11.1__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/CHANGELOG.md +22 -0
- plain/email/README.md +12 -4
- plain/email/__init__.py +26 -13
- plain/email/backends/base.py +20 -6
- plain/email/backends/console.py +10 -4
- plain/email/backends/filebased.py +11 -5
- plain/email/backends/smtp.py +26 -20
- plain/email/default_settings.py +4 -4
- plain/email/message.py +121 -76
- plain/email/utils.py +4 -2
- plain_email-0.11.1.dist-info/METADATA +34 -0
- plain_email-0.11.1.dist-info/RECORD +15 -0
- plain_email-0.10.2.dist-info/METADATA +0 -26
- plain_email-0.10.2.dist-info/RECORD +0 -15
- {plain_email-0.10.2.dist-info → plain_email-0.11.1.dist-info}/WHEEL +0 -0
- {plain_email-0.10.2.dist-info → plain_email-0.11.1.dist-info}/licenses/LICENSE +0 -0
plain/email/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# plain-email changelog
|
|
2
2
|
|
|
3
|
+
## [0.11.1](https://github.com/dropseed/plain/releases/plain-email@0.11.1) (2025-10-06)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- Added comprehensive type annotations throughout the package for improved IDE support and type checking ([5a32120](https://github.com/dropseed/plain/commit/5a3212020c473d3a10763cedd0b0b7ca778911de))
|
|
8
|
+
|
|
9
|
+
### Upgrade instructions
|
|
10
|
+
|
|
11
|
+
- No changes required
|
|
12
|
+
|
|
13
|
+
## [0.11.0](https://github.com/dropseed/plain/releases/plain-email@0.11.0) (2025-09-19)
|
|
14
|
+
|
|
15
|
+
### What's changed
|
|
16
|
+
|
|
17
|
+
- Updated Python minimum requirement to 3.13 ([d86e307](https://github.com/dropseed/plain/commit/d86e307))
|
|
18
|
+
- Improved README with installation instructions and table of contents ([4ebecd1](https://github.com/dropseed/plain/commit/4ebecd1))
|
|
19
|
+
- Updated package description to "Everything you need to send email in Plain" ([4ebecd1](https://github.com/dropseed/plain/commit/4ebecd1))
|
|
20
|
+
|
|
21
|
+
### Upgrade instructions
|
|
22
|
+
|
|
23
|
+
- Update your Python version to 3.13 or higher
|
|
24
|
+
|
|
3
25
|
## [0.10.2](https://github.com/dropseed/plain/releases/plain-email@0.10.2) (2025-06-23)
|
|
4
26
|
|
|
5
27
|
### What's changed
|
plain/email/README.md
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
# plain.email
|
|
2
2
|
|
|
3
|
-
Everything you need to send email
|
|
3
|
+
**Everything you need to send email in Plain.**
|
|
4
|
+
|
|
5
|
+
- [Installation](#installation)
|
|
4
6
|
|
|
5
7
|
## Installation
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
Install the `plain.email` package from [PyPI](https://pypi.org/project/plain.email/):
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv add plain.email
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Add `plain.email` to your `INSTALLED_PACKAGES`:
|
|
8
16
|
|
|
9
17
|
```python
|
|
10
18
|
# settings.py
|
|
11
|
-
|
|
19
|
+
INSTALLED_PACKAGES = [
|
|
12
20
|
# ...
|
|
13
|
-
|
|
21
|
+
"plain.email",
|
|
14
22
|
]
|
|
15
23
|
```
|
plain/email/__init__.py
CHANGED
|
@@ -2,9 +2,16 @@
|
|
|
2
2
|
Tools for sending email.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
5
9
|
from plain.runtime import settings
|
|
6
10
|
from plain.utils.module_loading import import_string
|
|
7
11
|
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .backends.base import BaseEmailBackend
|
|
14
|
+
|
|
8
15
|
from .message import (
|
|
9
16
|
DEFAULT_ATTACHMENT_MIME_TYPE,
|
|
10
17
|
BadHeaderError,
|
|
@@ -36,7 +43,9 @@ __all__ = [
|
|
|
36
43
|
]
|
|
37
44
|
|
|
38
45
|
|
|
39
|
-
def get_connection(
|
|
46
|
+
def get_connection(
|
|
47
|
+
backend: str | None = None, fail_silently: bool = False, **kwds: Any
|
|
48
|
+
) -> BaseEmailBackend:
|
|
40
49
|
"""Load an email backend and return an instance of it.
|
|
41
50
|
|
|
42
51
|
If backend is None (default), use settings.EMAIL_BACKEND.
|
|
@@ -49,16 +58,16 @@ def get_connection(backend=None, fail_silently=False, **kwds):
|
|
|
49
58
|
|
|
50
59
|
|
|
51
60
|
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
|
-
):
|
|
61
|
+
subject: str,
|
|
62
|
+
message: str,
|
|
63
|
+
from_email: str | None,
|
|
64
|
+
recipient_list: list[str],
|
|
65
|
+
fail_silently: bool = False,
|
|
66
|
+
auth_user: str | None = None,
|
|
67
|
+
auth_password: str | None = None,
|
|
68
|
+
connection: BaseEmailBackend | None = None,
|
|
69
|
+
html_message: str | None = None,
|
|
70
|
+
) -> int:
|
|
62
71
|
"""
|
|
63
72
|
Easy wrapper for sending a single message to a recipient list. All members
|
|
64
73
|
of the recipient list will see the other recipients in the 'To' field.
|
|
@@ -85,8 +94,12 @@ def send_mail(
|
|
|
85
94
|
|
|
86
95
|
|
|
87
96
|
def send_mass_mail(
|
|
88
|
-
datatuple,
|
|
89
|
-
|
|
97
|
+
datatuple: tuple[tuple[str, str, str, list[str]], ...],
|
|
98
|
+
fail_silently: bool = False,
|
|
99
|
+
auth_user: str | None = None,
|
|
100
|
+
auth_password: str | None = None,
|
|
101
|
+
connection: BaseEmailBackend | None = None,
|
|
102
|
+
) -> int:
|
|
90
103
|
"""
|
|
91
104
|
Given a datatuple of (subject, message, from_email, recipient_list), send
|
|
92
105
|
each message to each recipient list. Return the number of emails sent.
|
plain/email/backends/base.py
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
"""Base email backend class."""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
|
|
10
|
+
from ..message import EmailMessage
|
|
11
|
+
|
|
3
12
|
|
|
4
13
|
class BaseEmailBackend:
|
|
5
14
|
"""
|
|
@@ -15,10 +24,10 @@ class BaseEmailBackend:
|
|
|
15
24
|
pass
|
|
16
25
|
"""
|
|
17
26
|
|
|
18
|
-
def __init__(self, fail_silently=False, **kwargs):
|
|
27
|
+
def __init__(self, fail_silently: bool = False, **kwargs: Any) -> None:
|
|
19
28
|
self.fail_silently = fail_silently
|
|
20
29
|
|
|
21
|
-
def open(self):
|
|
30
|
+
def open(self) -> None:
|
|
22
31
|
"""
|
|
23
32
|
Open a network connection.
|
|
24
33
|
|
|
@@ -37,11 +46,11 @@ class BaseEmailBackend:
|
|
|
37
46
|
"""
|
|
38
47
|
pass
|
|
39
48
|
|
|
40
|
-
def close(self):
|
|
49
|
+
def close(self) -> None:
|
|
41
50
|
"""Close a network connection."""
|
|
42
51
|
pass
|
|
43
52
|
|
|
44
|
-
def __enter__(self):
|
|
53
|
+
def __enter__(self) -> BaseEmailBackend:
|
|
45
54
|
try:
|
|
46
55
|
self.open()
|
|
47
56
|
except Exception:
|
|
@@ -49,10 +58,15 @@ class BaseEmailBackend:
|
|
|
49
58
|
raise
|
|
50
59
|
return self
|
|
51
60
|
|
|
52
|
-
def __exit__(
|
|
61
|
+
def __exit__(
|
|
62
|
+
self,
|
|
63
|
+
exc_type: type[BaseException] | None,
|
|
64
|
+
exc_value: BaseException | None,
|
|
65
|
+
traceback: TracebackType | None,
|
|
66
|
+
) -> None:
|
|
53
67
|
self.close()
|
|
54
68
|
|
|
55
|
-
def send_messages(self, email_messages):
|
|
69
|
+
def send_messages(self, email_messages: list[EmailMessage]) -> int:
|
|
56
70
|
"""
|
|
57
71
|
Send one or more EmailMessage objects and return the number of email
|
|
58
72
|
messages sent.
|
plain/email/backends/console.py
CHANGED
|
@@ -2,19 +2,25 @@
|
|
|
2
2
|
Email backend that writes messages to console instead of sending them.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
5
7
|
import sys
|
|
6
8
|
import threading
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
7
10
|
|
|
8
11
|
from .base import BaseEmailBackend
|
|
9
12
|
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from ..message import EmailMessage
|
|
15
|
+
|
|
10
16
|
|
|
11
17
|
class EmailBackend(BaseEmailBackend):
|
|
12
|
-
def __init__(self, *args, **kwargs):
|
|
18
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
13
19
|
self.stream = kwargs.pop("stream", sys.stdout)
|
|
14
20
|
self._lock = threading.RLock()
|
|
15
21
|
super().__init__(*args, **kwargs)
|
|
16
22
|
|
|
17
|
-
def write_message(self, message):
|
|
23
|
+
def write_message(self, message: EmailMessage) -> None:
|
|
18
24
|
msg = message.message()
|
|
19
25
|
msg_data = msg.as_bytes()
|
|
20
26
|
charset = (
|
|
@@ -25,10 +31,10 @@ class EmailBackend(BaseEmailBackend):
|
|
|
25
31
|
self.stream.write("-" * 79)
|
|
26
32
|
self.stream.write("\n")
|
|
27
33
|
|
|
28
|
-
def send_messages(self, email_messages):
|
|
34
|
+
def send_messages(self, email_messages: list[EmailMessage]) -> int:
|
|
29
35
|
"""Write all messages to the stream in a thread-safe way."""
|
|
30
36
|
if not email_messages:
|
|
31
|
-
return
|
|
37
|
+
return 0
|
|
32
38
|
msg_count = 0
|
|
33
39
|
with self._lock:
|
|
34
40
|
try:
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
"""Email backend that writes messages to a file."""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
3
5
|
import datetime
|
|
4
6
|
import os
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
5
8
|
|
|
6
9
|
from plain.exceptions import ImproperlyConfigured
|
|
7
10
|
from plain.runtime import settings
|
|
8
11
|
|
|
9
12
|
from .console import EmailBackend as ConsoleEmailBackend
|
|
10
13
|
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from ..message import EmailMessage
|
|
16
|
+
|
|
11
17
|
|
|
12
18
|
class EmailBackend(ConsoleEmailBackend):
|
|
13
|
-
def __init__(self, *args, file_path=None, **kwargs):
|
|
19
|
+
def __init__(self, *args: Any, file_path: str | None = None, **kwargs: Any) -> None:
|
|
14
20
|
self._fname = None
|
|
15
21
|
if file_path is not None:
|
|
16
22
|
self.file_path = file_path
|
|
@@ -38,12 +44,12 @@ class EmailBackend(ConsoleEmailBackend):
|
|
|
38
44
|
kwargs["stream"] = None
|
|
39
45
|
super().__init__(*args, **kwargs)
|
|
40
46
|
|
|
41
|
-
def write_message(self, message):
|
|
47
|
+
def write_message(self, message: EmailMessage) -> None:
|
|
42
48
|
self.stream.write(message.message().as_bytes() + b"\n")
|
|
43
49
|
self.stream.write(b"-" * 79)
|
|
44
50
|
self.stream.write(b"\n")
|
|
45
51
|
|
|
46
|
-
def _get_filename(self):
|
|
52
|
+
def _get_filename(self) -> str:
|
|
47
53
|
"""Return a unique file name."""
|
|
48
54
|
if self._fname is None:
|
|
49
55
|
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
@@ -51,13 +57,13 @@ class EmailBackend(ConsoleEmailBackend):
|
|
|
51
57
|
self._fname = os.path.join(self.file_path, fname)
|
|
52
58
|
return self._fname
|
|
53
59
|
|
|
54
|
-
def open(self):
|
|
60
|
+
def open(self) -> bool:
|
|
55
61
|
if self.stream is None:
|
|
56
62
|
self.stream = open(self._get_filename(), "ab")
|
|
57
63
|
return True
|
|
58
64
|
return False
|
|
59
65
|
|
|
60
|
-
def close(self):
|
|
66
|
+
def close(self) -> None:
|
|
61
67
|
try:
|
|
62
68
|
if self.stream is not None:
|
|
63
69
|
self.stream.close()
|
plain/email/backends/smtp.py
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
"""SMTP email backend class."""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
3
5
|
import smtplib
|
|
4
6
|
import ssl
|
|
5
7
|
import threading
|
|
6
8
|
from functools import cached_property
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
7
10
|
|
|
8
11
|
from plain.runtime import settings
|
|
9
12
|
|
|
@@ -11,6 +14,9 @@ from ..backends.base import BaseEmailBackend
|
|
|
11
14
|
from ..message import sanitize_address
|
|
12
15
|
from ..utils import DNS_NAME
|
|
13
16
|
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from ..message import EmailMessage
|
|
19
|
+
|
|
14
20
|
|
|
15
21
|
class EmailBackend(BaseEmailBackend):
|
|
16
22
|
"""
|
|
@@ -19,18 +25,18 @@ class EmailBackend(BaseEmailBackend):
|
|
|
19
25
|
|
|
20
26
|
def __init__(
|
|
21
27
|
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
|
-
):
|
|
28
|
+
host: str | None = None,
|
|
29
|
+
port: int | None = None,
|
|
30
|
+
username: str | None = None,
|
|
31
|
+
password: str | None = None,
|
|
32
|
+
use_tls: bool | None = None,
|
|
33
|
+
fail_silently: bool = False,
|
|
34
|
+
use_ssl: bool | None = None,
|
|
35
|
+
timeout: int | None = None,
|
|
36
|
+
ssl_keyfile: str | None = None,
|
|
37
|
+
ssl_certfile: str | None = None,
|
|
38
|
+
**kwargs: Any,
|
|
39
|
+
) -> None:
|
|
34
40
|
super().__init__(fail_silently=fail_silently)
|
|
35
41
|
self.host = host or settings.EMAIL_HOST
|
|
36
42
|
self.port = port or settings.EMAIL_PORT
|
|
@@ -54,11 +60,11 @@ class EmailBackend(BaseEmailBackend):
|
|
|
54
60
|
self._lock = threading.RLock()
|
|
55
61
|
|
|
56
62
|
@property
|
|
57
|
-
def connection_class(self):
|
|
63
|
+
def connection_class(self) -> type[smtplib.SMTP]:
|
|
58
64
|
return smtplib.SMTP_SSL if self.use_ssl else smtplib.SMTP
|
|
59
65
|
|
|
60
66
|
@cached_property
|
|
61
|
-
def ssl_context(self):
|
|
67
|
+
def ssl_context(self) -> ssl.SSLContext:
|
|
62
68
|
if self.ssl_certfile or self.ssl_keyfile:
|
|
63
69
|
ssl_context = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_CLIENT)
|
|
64
70
|
ssl_context.load_cert_chain(self.ssl_certfile, self.ssl_keyfile)
|
|
@@ -66,7 +72,7 @@ class EmailBackend(BaseEmailBackend):
|
|
|
66
72
|
else:
|
|
67
73
|
return ssl.create_default_context()
|
|
68
74
|
|
|
69
|
-
def open(self):
|
|
75
|
+
def open(self) -> bool | None:
|
|
70
76
|
"""
|
|
71
77
|
Ensure an open connection to the email server. Return whether or not a
|
|
72
78
|
new connection was required (True or False) or None if an exception
|
|
@@ -99,10 +105,10 @@ class EmailBackend(BaseEmailBackend):
|
|
|
99
105
|
if not self.fail_silently:
|
|
100
106
|
raise
|
|
101
107
|
|
|
102
|
-
def close(self):
|
|
108
|
+
def close(self) -> None:
|
|
103
109
|
"""Close the connection to the email server."""
|
|
104
110
|
if self.connection is None:
|
|
105
|
-
return
|
|
111
|
+
return None
|
|
106
112
|
try:
|
|
107
113
|
try:
|
|
108
114
|
self.connection.quit()
|
|
@@ -113,12 +119,12 @@ class EmailBackend(BaseEmailBackend):
|
|
|
113
119
|
self.connection.close()
|
|
114
120
|
except smtplib.SMTPException:
|
|
115
121
|
if self.fail_silently:
|
|
116
|
-
return
|
|
122
|
+
return None
|
|
117
123
|
raise
|
|
118
124
|
finally:
|
|
119
125
|
self.connection = None
|
|
120
126
|
|
|
121
|
-
def send_messages(self, email_messages):
|
|
127
|
+
def send_messages(self, email_messages: list[EmailMessage]) -> int:
|
|
122
128
|
"""
|
|
123
129
|
Send one or more EmailMessage objects and return the number of email
|
|
124
130
|
messages sent.
|
|
@@ -142,7 +148,7 @@ class EmailBackend(BaseEmailBackend):
|
|
|
142
148
|
self.close()
|
|
143
149
|
return num_sent
|
|
144
150
|
|
|
145
|
-
def _send(self, email_message):
|
|
151
|
+
def _send(self, email_message: EmailMessage) -> bool:
|
|
146
152
|
"""A helper method that does the actual sending."""
|
|
147
153
|
if not email_message.recipients():
|
|
148
154
|
return False
|
plain/email/default_settings.py
CHANGED
|
@@ -6,7 +6,7 @@ EMAIL_BACKEND: str
|
|
|
6
6
|
|
|
7
7
|
EMAIL_DEFAULT_FROM: str
|
|
8
8
|
|
|
9
|
-
EMAIL_DEFAULT_REPLY_TO: list[str] = None
|
|
9
|
+
EMAIL_DEFAULT_REPLY_TO: list[str] | None = None
|
|
10
10
|
|
|
11
11
|
# Host for sending email.
|
|
12
12
|
EMAIL_HOST: str = "localhost"
|
|
@@ -22,6 +22,6 @@ EMAIL_HOST_USER: str = ""
|
|
|
22
22
|
EMAIL_HOST_PASSWORD: str = ""
|
|
23
23
|
EMAIL_USE_TLS: bool = True
|
|
24
24
|
EMAIL_USE_SSL: bool = False
|
|
25
|
-
EMAIL_SSL_CERTFILE: str = None
|
|
26
|
-
EMAIL_SSL_KEYFILE: str = None
|
|
27
|
-
EMAIL_TIMEOUT: int = None
|
|
25
|
+
EMAIL_SSL_CERTFILE: str | None = None
|
|
26
|
+
EMAIL_SSL_KEYFILE: str | None = None
|
|
27
|
+
EMAIL_TIMEOUT: int | None = None
|
plain/email/message.py
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import mimetypes
|
|
2
4
|
from email import charset as Charset
|
|
3
5
|
from email import encoders as Encoders
|
|
4
6
|
from email import generator, message_from_string
|
|
5
7
|
from email.errors import HeaderParseError
|
|
6
8
|
from email.header import Header
|
|
7
|
-
from email.headerregistry import Address
|
|
9
|
+
from email.headerregistry import Address
|
|
10
|
+
from email.headerregistry import (
|
|
11
|
+
parser as headerregistry_parser, # type: ignore[attr-defined]
|
|
12
|
+
)
|
|
8
13
|
from email.message import Message
|
|
9
14
|
from email.mime.base import MIMEBase
|
|
10
15
|
from email.mime.message import MIMEMessage
|
|
@@ -13,6 +18,7 @@ from email.mime.text import MIMEText
|
|
|
13
18
|
from email.utils import formataddr, formatdate, getaddresses, make_msgid
|
|
14
19
|
from io import BytesIO, StringIO
|
|
15
20
|
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
16
22
|
|
|
17
23
|
from plain.runtime import settings
|
|
18
24
|
from plain.templates import Template, TemplateFileMissing
|
|
@@ -21,10 +27,15 @@ from plain.utils.html import strip_tags
|
|
|
21
27
|
|
|
22
28
|
from .utils import DNS_NAME
|
|
23
29
|
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from os import PathLike
|
|
32
|
+
|
|
33
|
+
from .backends.base import BaseEmailBackend
|
|
34
|
+
|
|
24
35
|
# Don't BASE64-encode UTF-8 messages so that we avoid unwanted attention from
|
|
25
36
|
# some spam filters.
|
|
26
37
|
utf8_charset = Charset.Charset("utf-8")
|
|
27
|
-
utf8_charset.body_encoding = None # Python defaults to BASE64
|
|
38
|
+
utf8_charset.body_encoding = None # type: ignore[assignment] # Python defaults to BASE64
|
|
28
39
|
utf8_charset_qp = Charset.Charset("utf-8")
|
|
29
40
|
utf8_charset_qp.body_encoding = Charset.QP
|
|
30
41
|
|
|
@@ -55,7 +66,9 @@ ADDRESS_HEADERS = {
|
|
|
55
66
|
}
|
|
56
67
|
|
|
57
68
|
|
|
58
|
-
def forbid_multi_line_headers(
|
|
69
|
+
def forbid_multi_line_headers(
|
|
70
|
+
name: str, val: str, encoding: str | None
|
|
71
|
+
) -> tuple[str, str]:
|
|
59
72
|
"""Forbid multi-line headers to prevent header injection."""
|
|
60
73
|
encoding = encoding or settings.DEFAULT_CHARSET
|
|
61
74
|
val = str(val) # val may be lazy
|
|
@@ -78,7 +91,7 @@ def forbid_multi_line_headers(name, val, encoding):
|
|
|
78
91
|
return name, val
|
|
79
92
|
|
|
80
93
|
|
|
81
|
-
def sanitize_address(addr, encoding):
|
|
94
|
+
def sanitize_address(addr: str | tuple[str, str], encoding: str) -> str:
|
|
82
95
|
"""
|
|
83
96
|
Format a pair of (name, address) or an email address string.
|
|
84
97
|
"""
|
|
@@ -86,7 +99,7 @@ def sanitize_address(addr, encoding):
|
|
|
86
99
|
if not isinstance(addr, tuple):
|
|
87
100
|
addr = force_str(addr)
|
|
88
101
|
try:
|
|
89
|
-
token, rest =
|
|
102
|
+
token, rest = headerregistry_parser.get_mailbox(addr)
|
|
90
103
|
except (HeaderParseError, ValueError, IndexError):
|
|
91
104
|
raise ValueError(f'Invalid address "{addr}"')
|
|
92
105
|
else:
|
|
@@ -123,7 +136,7 @@ def sanitize_address(addr, encoding):
|
|
|
123
136
|
|
|
124
137
|
|
|
125
138
|
class MIMEMixin:
|
|
126
|
-
def as_string(self, unixfrom=False, linesep="\n"):
|
|
139
|
+
def as_string(self, unixfrom: bool = False, linesep: str = "\n") -> str:
|
|
127
140
|
"""Return the entire formatted message as a string.
|
|
128
141
|
Optional `unixfrom' when True, means include the Unix From_ envelope
|
|
129
142
|
header.
|
|
@@ -136,7 +149,7 @@ class MIMEMixin:
|
|
|
136
149
|
g.flatten(self, unixfrom=unixfrom, linesep=linesep)
|
|
137
150
|
return fp.getvalue()
|
|
138
151
|
|
|
139
|
-
def as_bytes(self, unixfrom=False, linesep="\n"):
|
|
152
|
+
def as_bytes(self, unixfrom: bool = False, linesep: str = "\n") -> bytes:
|
|
140
153
|
"""Return the entire formatted message as bytes.
|
|
141
154
|
Optional `unixfrom' when True, means include the Unix From_ envelope
|
|
142
155
|
header.
|
|
@@ -151,22 +164,26 @@ class MIMEMixin:
|
|
|
151
164
|
|
|
152
165
|
|
|
153
166
|
class SafeMIMEMessage(MIMEMixin, MIMEMessage):
|
|
154
|
-
def __setitem__(self, name, val):
|
|
167
|
+
def __setitem__(self, name: str, val: str) -> None:
|
|
155
168
|
# message/rfc822 attachments must be ASCII
|
|
156
169
|
name, val = forbid_multi_line_headers(name, val, "ascii")
|
|
157
170
|
MIMEMessage.__setitem__(self, name, val)
|
|
158
171
|
|
|
159
172
|
|
|
160
173
|
class SafeMIMEText(MIMEMixin, MIMEText):
|
|
161
|
-
def __init__(
|
|
174
|
+
def __init__(
|
|
175
|
+
self, _text: str, _subtype: str = "plain", _charset: str | None = None
|
|
176
|
+
) -> None:
|
|
162
177
|
self.encoding = _charset
|
|
163
178
|
MIMEText.__init__(self, _text, _subtype=_subtype, _charset=_charset)
|
|
164
179
|
|
|
165
|
-
def __setitem__(self, name, val):
|
|
180
|
+
def __setitem__(self, name: str, val: str) -> None:
|
|
166
181
|
name, val = forbid_multi_line_headers(name, val, self.encoding)
|
|
167
182
|
MIMEText.__setitem__(self, name, val)
|
|
168
183
|
|
|
169
|
-
def set_payload(
|
|
184
|
+
def set_payload(
|
|
185
|
+
self, payload: str, charset: str | Charset.Charset | None = None
|
|
186
|
+
) -> None:
|
|
170
187
|
if charset == "utf-8" and not isinstance(charset, Charset.Charset):
|
|
171
188
|
has_long_lines = any(
|
|
172
189
|
len(line.encode()) > RFC5322_EMAIL_LINE_LENGTH_LIMIT
|
|
@@ -180,12 +197,17 @@ class SafeMIMEText(MIMEMixin, MIMEText):
|
|
|
180
197
|
|
|
181
198
|
class SafeMIMEMultipart(MIMEMixin, MIMEMultipart):
|
|
182
199
|
def __init__(
|
|
183
|
-
self,
|
|
184
|
-
|
|
200
|
+
self,
|
|
201
|
+
_subtype: str = "mixed",
|
|
202
|
+
boundary: str | None = None,
|
|
203
|
+
_subparts: list[Message] | None = None,
|
|
204
|
+
encoding: str | None = None,
|
|
205
|
+
**_params: Any,
|
|
206
|
+
) -> None:
|
|
185
207
|
self.encoding = encoding
|
|
186
208
|
MIMEMultipart.__init__(self, _subtype, boundary, _subparts, **_params)
|
|
187
209
|
|
|
188
|
-
def __setitem__(self, name, val):
|
|
210
|
+
def __setitem__(self, name: str, val: str) -> None:
|
|
189
211
|
name, val = forbid_multi_line_headers(name, val, self.encoding)
|
|
190
212
|
MIMEMultipart.__setitem__(self, name, val)
|
|
191
213
|
|
|
@@ -195,21 +217,21 @@ class EmailMessage:
|
|
|
195
217
|
|
|
196
218
|
content_subtype = "plain"
|
|
197
219
|
mixed_subtype = "mixed"
|
|
198
|
-
encoding = None # None => use settings default
|
|
220
|
+
encoding: str | None = None # None => use settings default
|
|
199
221
|
|
|
200
222
|
def __init__(
|
|
201
223
|
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
|
-
):
|
|
224
|
+
subject: str = "",
|
|
225
|
+
body: str = "",
|
|
226
|
+
from_email: str | None = None,
|
|
227
|
+
to: list[str] | tuple[str, ...] | None = None,
|
|
228
|
+
bcc: list[str] | tuple[str, ...] | None = None,
|
|
229
|
+
connection: BaseEmailBackend | None = None,
|
|
230
|
+
attachments: list[MIMEBase | tuple[str, str, str]] | None = None,
|
|
231
|
+
headers: dict[str, str] | None = None,
|
|
232
|
+
cc: list[str] | tuple[str, ...] | None = None,
|
|
233
|
+
reply_to: list[str] | tuple[str, ...] | None = None,
|
|
234
|
+
) -> None:
|
|
213
235
|
"""
|
|
214
236
|
Initialize a single email message (which can be sent to multiple
|
|
215
237
|
recipients).
|
|
@@ -251,14 +273,14 @@ class EmailMessage:
|
|
|
251
273
|
self.extra_headers = headers or {}
|
|
252
274
|
self.connection = connection
|
|
253
275
|
|
|
254
|
-
def get_connection(self, fail_silently=False):
|
|
276
|
+
def get_connection(self, fail_silently: bool = False) -> BaseEmailBackend:
|
|
255
277
|
from . import get_connection
|
|
256
278
|
|
|
257
279
|
if not self.connection:
|
|
258
280
|
self.connection = get_connection(fail_silently=fail_silently)
|
|
259
281
|
return self.connection
|
|
260
282
|
|
|
261
|
-
def message(self):
|
|
283
|
+
def message(self) -> SafeMIMEText | SafeMIMEMultipart:
|
|
262
284
|
encoding = self.encoding or settings.DEFAULT_CHARSET
|
|
263
285
|
msg = SafeMIMEText(self.body, self.content_subtype, encoding)
|
|
264
286
|
msg = self._create_message(msg)
|
|
@@ -279,20 +301,20 @@ class EmailMessage:
|
|
|
279
301
|
msg["Date"] = formatdate(localtime=settings.EMAIL_USE_LOCALTIME)
|
|
280
302
|
if "message-id" not in header_names:
|
|
281
303
|
# Use cached DNS_NAME for performance
|
|
282
|
-
msg["Message-ID"] = make_msgid(domain=DNS_NAME)
|
|
304
|
+
msg["Message-ID"] = make_msgid(domain=str(DNS_NAME))
|
|
283
305
|
for name, value in self.extra_headers.items():
|
|
284
306
|
if name.lower() != "from": # From is already handled
|
|
285
307
|
msg[name] = value
|
|
286
308
|
return msg
|
|
287
309
|
|
|
288
|
-
def recipients(self):
|
|
310
|
+
def recipients(self) -> list[str]:
|
|
289
311
|
"""
|
|
290
312
|
Return a list of all recipients of the email (includes direct
|
|
291
313
|
addressees as well as Cc and Bcc entries).
|
|
292
314
|
"""
|
|
293
315
|
return [email for email in (self.to + self.cc + self.bcc) if email]
|
|
294
316
|
|
|
295
|
-
def send(self, fail_silently=False):
|
|
317
|
+
def send(self, fail_silently: bool = False) -> int:
|
|
296
318
|
"""Send the email message."""
|
|
297
319
|
if not self.recipients():
|
|
298
320
|
# Don't bother creating the network connection if there's nobody to
|
|
@@ -300,7 +322,12 @@ class EmailMessage:
|
|
|
300
322
|
return 0
|
|
301
323
|
return self.get_connection(fail_silently).send_messages([self])
|
|
302
324
|
|
|
303
|
-
def attach(
|
|
325
|
+
def attach(
|
|
326
|
+
self,
|
|
327
|
+
filename: MIMEBase | str | None = None,
|
|
328
|
+
content: str | bytes | None = None,
|
|
329
|
+
mimetype: str | None = None,
|
|
330
|
+
) -> None:
|
|
304
331
|
"""
|
|
305
332
|
Attach a file with the given filename and content. The filename can
|
|
306
333
|
be omitted and the mimetype is guessed, if not provided.
|
|
@@ -340,7 +367,9 @@ class EmailMessage:
|
|
|
340
367
|
|
|
341
368
|
self.attachments.append((filename, content, mimetype))
|
|
342
369
|
|
|
343
|
-
def attach_file(
|
|
370
|
+
def attach_file(
|
|
371
|
+
self, path: str | PathLike[str], mimetype: str | None = None
|
|
372
|
+
) -> None:
|
|
344
373
|
"""
|
|
345
374
|
Attach a file from the filesystem.
|
|
346
375
|
|
|
@@ -356,10 +385,12 @@ class EmailMessage:
|
|
|
356
385
|
content = file.read()
|
|
357
386
|
self.attach(path.name, content, mimetype)
|
|
358
387
|
|
|
359
|
-
def _create_message(self, msg):
|
|
388
|
+
def _create_message(self, msg: SafeMIMEText) -> SafeMIMEText | SafeMIMEMultipart:
|
|
360
389
|
return self._create_attachments(msg)
|
|
361
390
|
|
|
362
|
-
def _create_attachments(
|
|
391
|
+
def _create_attachments(
|
|
392
|
+
self, msg: SafeMIMEText | SafeMIMEMultipart
|
|
393
|
+
) -> SafeMIMEText | SafeMIMEMultipart:
|
|
363
394
|
if self.attachments:
|
|
364
395
|
encoding = self.encoding or settings.DEFAULT_CHARSET
|
|
365
396
|
body_msg = msg
|
|
@@ -373,7 +404,9 @@ class EmailMessage:
|
|
|
373
404
|
msg.attach(self._create_attachment(*attachment))
|
|
374
405
|
return msg
|
|
375
406
|
|
|
376
|
-
def _create_mime_attachment(
|
|
407
|
+
def _create_mime_attachment(
|
|
408
|
+
self, content: str | bytes | EmailMessage | Message, mimetype: str
|
|
409
|
+
) -> SafeMIMEText | SafeMIMEMessage | MIMEBase:
|
|
377
410
|
"""
|
|
378
411
|
Convert the content, mimetype pair into a MIME attachment object.
|
|
379
412
|
|
|
@@ -383,6 +416,8 @@ class EmailMessage:
|
|
|
383
416
|
basetype, subtype = mimetype.split("/", 1)
|
|
384
417
|
if basetype == "text":
|
|
385
418
|
encoding = self.encoding or settings.DEFAULT_CHARSET
|
|
419
|
+
if not isinstance(content, str):
|
|
420
|
+
content = force_str(content)
|
|
386
421
|
attachment = SafeMIMEText(content, subtype, encoding)
|
|
387
422
|
elif basetype == "message" and subtype == "rfc822":
|
|
388
423
|
# Bug #18967: Per RFC 2046 Section 5.2.1, message/rfc822
|
|
@@ -403,7 +438,9 @@ class EmailMessage:
|
|
|
403
438
|
Encoders.encode_base64(attachment)
|
|
404
439
|
return attachment
|
|
405
440
|
|
|
406
|
-
def _create_attachment(
|
|
441
|
+
def _create_attachment(
|
|
442
|
+
self, filename: str | None, content: str | bytes, mimetype: str | None = None
|
|
443
|
+
) -> SafeMIMEText | SafeMIMEMessage | MIMEBase:
|
|
407
444
|
"""
|
|
408
445
|
Convert the filename, content, mimetype triple into a MIME attachment
|
|
409
446
|
object.
|
|
@@ -412,14 +449,20 @@ class EmailMessage:
|
|
|
412
449
|
if filename:
|
|
413
450
|
try:
|
|
414
451
|
filename.encode("ascii")
|
|
452
|
+
encoded_filename: str | tuple[str, str, str] = filename
|
|
415
453
|
except UnicodeEncodeError:
|
|
416
|
-
|
|
454
|
+
encoded_filename = ("utf-8", "", filename)
|
|
417
455
|
attachment.add_header(
|
|
418
|
-
"Content-Disposition", "attachment", filename=
|
|
456
|
+
"Content-Disposition", "attachment", filename=encoded_filename
|
|
419
457
|
)
|
|
420
458
|
return attachment
|
|
421
459
|
|
|
422
|
-
def _set_list_header_if_not_empty(
|
|
460
|
+
def _set_list_header_if_not_empty(
|
|
461
|
+
self,
|
|
462
|
+
msg: SafeMIMEText | SafeMIMEMultipart,
|
|
463
|
+
header: str,
|
|
464
|
+
values: list[str],
|
|
465
|
+
) -> None:
|
|
423
466
|
"""
|
|
424
467
|
Set msg's header, either from self.extra_headers, if present, or from
|
|
425
468
|
the values argument.
|
|
@@ -443,18 +486,18 @@ class EmailMultiAlternatives(EmailMessage):
|
|
|
443
486
|
|
|
444
487
|
def __init__(
|
|
445
488
|
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
|
-
):
|
|
489
|
+
subject: str = "",
|
|
490
|
+
body: str = "",
|
|
491
|
+
from_email: str | None = None,
|
|
492
|
+
to: list[str] | tuple[str, ...] | None = None,
|
|
493
|
+
bcc: list[str] | tuple[str, ...] | None = None,
|
|
494
|
+
connection: BaseEmailBackend | None = None,
|
|
495
|
+
attachments: list[MIMEBase | tuple[str, str, str]] | None = None,
|
|
496
|
+
headers: dict[str, str] | None = None,
|
|
497
|
+
alternatives: list[tuple[str, str]] | None = None,
|
|
498
|
+
cc: list[str] | tuple[str, ...] | None = None,
|
|
499
|
+
reply_to: list[str] | tuple[str, ...] | None = None,
|
|
500
|
+
) -> None:
|
|
458
501
|
"""
|
|
459
502
|
Initialize a single email message (which can be sent to multiple
|
|
460
503
|
recipients).
|
|
@@ -473,16 +516,18 @@ class EmailMultiAlternatives(EmailMessage):
|
|
|
473
516
|
)
|
|
474
517
|
self.alternatives = alternatives or []
|
|
475
518
|
|
|
476
|
-
def attach_alternative(self, content, mimetype):
|
|
519
|
+
def attach_alternative(self, content: str, mimetype: str) -> None:
|
|
477
520
|
"""Attach an alternative content representation."""
|
|
478
521
|
if content is None or mimetype is None:
|
|
479
522
|
raise ValueError("Both content and mimetype must be provided.")
|
|
480
523
|
self.alternatives.append((content, mimetype))
|
|
481
524
|
|
|
482
|
-
def _create_message(self, msg):
|
|
525
|
+
def _create_message(self, msg: SafeMIMEText) -> SafeMIMEText | SafeMIMEMultipart:
|
|
483
526
|
return self._create_attachments(self._create_alternatives(msg))
|
|
484
527
|
|
|
485
|
-
def _create_alternatives(
|
|
528
|
+
def _create_alternatives(
|
|
529
|
+
self, msg: SafeMIMEText | SafeMIMEMultipart
|
|
530
|
+
) -> SafeMIMEText | SafeMIMEMultipart:
|
|
486
531
|
encoding = self.encoding or settings.DEFAULT_CHARSET
|
|
487
532
|
if self.alternatives:
|
|
488
533
|
body_msg = msg
|
|
@@ -500,19 +545,19 @@ class TemplateEmail(EmailMultiAlternatives):
|
|
|
500
545
|
def __init__(
|
|
501
546
|
self,
|
|
502
547
|
*,
|
|
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
|
-
):
|
|
548
|
+
template: str,
|
|
549
|
+
context: dict[str, Any] | None = None,
|
|
550
|
+
subject: str = "",
|
|
551
|
+
from_email: str | None = None,
|
|
552
|
+
to: list[str] | tuple[str, ...] | None = None,
|
|
553
|
+
bcc: list[str] | tuple[str, ...] | None = None,
|
|
554
|
+
connection: BaseEmailBackend | None = None,
|
|
555
|
+
attachments: list[MIMEBase | tuple[str, str, str]] | None = None,
|
|
556
|
+
headers: dict[str, str] | None = None,
|
|
557
|
+
alternatives: list[tuple[str, str]] | None = None,
|
|
558
|
+
cc: list[str] | tuple[str, ...] | None = None,
|
|
559
|
+
reply_to: list[str] | tuple[str, ...] | None = None,
|
|
560
|
+
) -> None:
|
|
516
561
|
self.template = template
|
|
517
562
|
self.context = context or {}
|
|
518
563
|
|
|
@@ -540,11 +585,11 @@ class TemplateEmail(EmailMultiAlternatives):
|
|
|
540
585
|
|
|
541
586
|
self.attach_alternative(self.body_html, "text/html")
|
|
542
587
|
|
|
543
|
-
def get_template_context(self):
|
|
588
|
+
def get_template_context(self) -> dict[str, Any]:
|
|
544
589
|
"""Subclasses can override this method to add context data."""
|
|
545
590
|
return self.context
|
|
546
591
|
|
|
547
|
-
def render_content(self, context):
|
|
592
|
+
def render_content(self, context: dict[str, Any]) -> tuple[str, str]:
|
|
548
593
|
html_content = self.render_html(context)
|
|
549
594
|
|
|
550
595
|
try:
|
|
@@ -554,24 +599,24 @@ class TemplateEmail(EmailMultiAlternatives):
|
|
|
554
599
|
|
|
555
600
|
return html_content, plain_content
|
|
556
601
|
|
|
557
|
-
def render_plain(self, context):
|
|
602
|
+
def render_plain(self, context: dict[str, Any]) -> str:
|
|
558
603
|
return Template(self.get_plain_template_name()).render(context)
|
|
559
604
|
|
|
560
|
-
def render_html(self, context):
|
|
605
|
+
def render_html(self, context: dict[str, Any]) -> str:
|
|
561
606
|
return Template(self.get_html_template_name()).render(context)
|
|
562
607
|
|
|
563
|
-
def render_subject(self, context):
|
|
608
|
+
def render_subject(self, context: dict[str, Any]) -> str:
|
|
564
609
|
try:
|
|
565
610
|
subject = Template(self.get_subject_template_name()).render(context)
|
|
566
611
|
return subject.strip()
|
|
567
612
|
except TemplateFileMissing:
|
|
568
613
|
return ""
|
|
569
614
|
|
|
570
|
-
def get_plain_template_name(self):
|
|
615
|
+
def get_plain_template_name(self) -> str:
|
|
571
616
|
return f"email/{self.template}.txt"
|
|
572
617
|
|
|
573
|
-
def get_html_template_name(self):
|
|
618
|
+
def get_html_template_name(self) -> str:
|
|
574
619
|
return f"email/{self.template}.html"
|
|
575
620
|
|
|
576
|
-
def get_subject_template_name(self):
|
|
621
|
+
def get_subject_template_name(self) -> str:
|
|
577
622
|
return f"email/{self.template}.subject.txt"
|
plain/email/utils.py
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
Email message and email sending related helper functions.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
5
7
|
import socket
|
|
6
8
|
|
|
7
9
|
from plain.utils.encoding import punycode
|
|
@@ -10,10 +12,10 @@ from plain.utils.encoding import punycode
|
|
|
10
12
|
# Cache the hostname, but do it lazily: socket.getfqdn() can take a couple of
|
|
11
13
|
# seconds, which slows down the restart of the server.
|
|
12
14
|
class CachedDnsName:
|
|
13
|
-
def __str__(self):
|
|
15
|
+
def __str__(self) -> str:
|
|
14
16
|
return self.get_fqdn()
|
|
15
17
|
|
|
16
|
-
def get_fqdn(self):
|
|
18
|
+
def get_fqdn(self) -> str:
|
|
17
19
|
if not hasattr(self, "_fqdn"):
|
|
18
20
|
self._fqdn = punycode(socket.getfqdn())
|
|
19
21
|
return self._fqdn
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: plain.email
|
|
3
|
+
Version: 0.11.1
|
|
4
|
+
Summary: Everything you need to send email in Plain.
|
|
5
|
+
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.13
|
|
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 in Plain.**
|
|
15
|
+
|
|
16
|
+
- [Installation](#installation)
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
Install the `plain.email` package from [PyPI](https://pypi.org/project/plain.email/):
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uv add plain.email
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Add `plain.email` to your `INSTALLED_PACKAGES`:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
# settings.py
|
|
30
|
+
INSTALLED_PACKAGES = [
|
|
31
|
+
# ...
|
|
32
|
+
"plain.email",
|
|
33
|
+
]
|
|
34
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
plain/email/CHANGELOG.md,sha256=EWH5S__yU2j27sC9FIyzT_fWsyhnRZlJUjiyi4kxdDo,1365
|
|
2
|
+
plain/email/README.md,sha256=F8R2Mu_U-jOEa2HVTzVpODGD-Ml6jbn9KYnJLl8p6lI,364
|
|
3
|
+
plain/email/__init__.py,sha256=CceyW_Lf4OC6VIkPPvstwNT6Z-RtIkyym_k_s8HMw5E,3691
|
|
4
|
+
plain/email/default_settings.py,sha256=B2V9UOq4MDG5VQM9VKTRiBqQ6ppUyoRFq9j7Qi9bO_M,811
|
|
5
|
+
plain/email/message.py,sha256=KK8vjrPJ6OZv44nkqShuZ2JJYwT0nRVL6GErAVBtn8o,22884
|
|
6
|
+
plain/email/utils.py,sha256=1B12SBVCpbLVN5bSaewiEpcDdz-7KM2SpXPC33Dcef8,555
|
|
7
|
+
plain/email/backends/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
plain/email/backends/base.py,sha256=wAcQ9XVc8SU1Qnasu62k70A4ZtOzJBMiZEOLhSyxM3U,2056
|
|
9
|
+
plain/email/backends/console.py,sha256=pnW290EbywujyEnRa2piM-XOIofB5Cp95c5zjd4pxak,1601
|
|
10
|
+
plain/email/backends/filebased.py,sha256=lDHdwVNP6t3xpaHwvCqzXIHcfUdzfN3gme1w3Dce8CU,2486
|
|
11
|
+
plain/email/backends/smtp.py,sha256=sBhhK6zZ6PNsmLHwGjEQ_is0qSkPGS8_09EoSrnfCi8,6150
|
|
12
|
+
plain_email-0.11.1.dist-info/METADATA,sha256=YkHBwZOKkzRIt3ldwEBPe_Yr-ncH_zT0m-9QeNEQryU,677
|
|
13
|
+
plain_email-0.11.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
14
|
+
plain_email-0.11.1.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
|
|
15
|
+
plain_email-0.11.1.dist-info/RECORD,,
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: plain.email
|
|
3
|
-
Version: 0.10.2
|
|
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
|
-
```
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
plain/email/CHANGELOG.md,sha256=OSxfA0UHsBeL2-cSTo6Nmq8fIvOOMqTqieGCyl6_2w0,442
|
|
2
|
-
plain/email/README.md,sha256=6aI9gU-ucBmxOwarCCsS8EJYfRVZlKtkYCBm7UwodJc,191
|
|
3
|
-
plain/email/__init__.py,sha256=rxQVZrXePn7K0iS7-AEWJKtHHIT2AWTe62HBWCUdX2M,3247
|
|
4
|
-
plain/email/default_settings.py,sha256=XTdQPo0KlOxbPXVlgxMdXWACG-qsZaSEMtkj8cQVIt4,783
|
|
5
|
-
plain/email/message.py,sha256=YjmO-DRvUqGIDEVNTuSIfsTtOeHas37a_9AZNGzsr1w,20044
|
|
6
|
-
plain/email/utils.py,sha256=ez-G2FUqRtozr-pXL6k0pceSqyPITilqiNc4hm3e45E,505
|
|
7
|
-
plain/email/backends/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
-
plain/email/backends/base.py,sha256=Cljbb7nil40Dfpob2R8iLmlO0Yv_wlOCBA9hF2Z6W54,1683
|
|
9
|
-
plain/email/backends/console.py,sha256=QhB_GBNIct7gwoTnVQNYqdLSRB1Hd8vl0U1eZuL_kMM,1400
|
|
10
|
-
plain/email/backends/filebased.py,sha256=uYgDWM5Hyl4GpMQhAjE5yKkxshQ8WgJWZ0ZZDK8mQoI,2277
|
|
11
|
-
plain/email/backends/smtp.py,sha256=uHj5rI-S4eNBeNUasKEFedwR_tGfZwUIWgQGM_jc7Ag,5747
|
|
12
|
-
plain_email-0.10.2.dist-info/METADATA,sha256=67YbiRQa1J_VBjlT3jmqFk1aGBzxBYKzXqbAfIMWYJ8,485
|
|
13
|
-
plain_email-0.10.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
14
|
-
plain_email-0.10.2.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
|
|
15
|
-
plain_email-0.10.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|