plain.email 0.13.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/CHANGELOG.md +54 -0
- plain/email/README.md +23 -0
- plain/email/__init__.py +124 -0
- plain/email/backends/__init__.py +0 -0
- plain/email/backends/base.py +76 -0
- plain/email/backends/console.py +55 -0
- plain/email/backends/filebased.py +75 -0
- plain/email/backends/smtp.py +170 -0
- plain/email/default_settings.py +27 -0
- plain/email/message.py +622 -0
- plain/email/utils.py +24 -0
- plain_email-0.13.0.dist-info/METADATA +34 -0
- plain_email-0.13.0.dist-info/RECORD +15 -0
- plain_email-0.13.0.dist-info/WHEEL +4 -0
- plain_email-0.13.0.dist-info/licenses/LICENSE +61 -0
plain/email/CHANGELOG.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# plain-email changelog
|
|
2
|
+
|
|
3
|
+
## [0.13.0](https://github.com/dropseed/plain/releases/plain-email@0.13.0) (2025-12-04)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- Internal typing improvements for better type checker compatibility ([ac1eeb0](https://github.com/dropseed/plain/commit/ac1eeb0ea05b26dfc7e32c50f2a5a5bc7e098ceb))
|
|
8
|
+
|
|
9
|
+
### Upgrade instructions
|
|
10
|
+
|
|
11
|
+
- No changes required
|
|
12
|
+
|
|
13
|
+
## [0.12.0](https://github.com/dropseed/plain/releases/plain-email@0.12.0) (2025-11-12)
|
|
14
|
+
|
|
15
|
+
### What's changed
|
|
16
|
+
|
|
17
|
+
- The filebased email backend now requires `EMAIL_FILE_PATH` to be set and raises `ImproperlyConfigured` if not provided ([f4dbcef](https://github.com/dropseed/plain/commit/f4dbcefa929058be517cb1d4ab35bd73a89f26b8))
|
|
18
|
+
- `BaseEmailBackend` now uses Python's abstract base class with `@abstractmethod` for better type checking ([245b5f4](https://github.com/dropseed/plain/commit/245b5f472c89178b8b764869f1624f8fc885b0f7))
|
|
19
|
+
|
|
20
|
+
### Upgrade instructions
|
|
21
|
+
|
|
22
|
+
- If using the filebased email backend, ensure `EMAIL_FILE_PATH` is configured in your settings or passed when initializing the backend
|
|
23
|
+
|
|
24
|
+
## [0.11.1](https://github.com/dropseed/plain/releases/plain-email@0.11.1) (2025-10-06)
|
|
25
|
+
|
|
26
|
+
### What's changed
|
|
27
|
+
|
|
28
|
+
- Added comprehensive type annotations throughout the package for improved IDE support and type checking ([5a32120](https://github.com/dropseed/plain/commit/5a3212020c473d3a10763cedd0b0b7ca778911de))
|
|
29
|
+
|
|
30
|
+
### Upgrade instructions
|
|
31
|
+
|
|
32
|
+
- No changes required
|
|
33
|
+
|
|
34
|
+
## [0.11.0](https://github.com/dropseed/plain/releases/plain-email@0.11.0) (2025-09-19)
|
|
35
|
+
|
|
36
|
+
### What's changed
|
|
37
|
+
|
|
38
|
+
- Updated Python minimum requirement to 3.13 ([d86e307](https://github.com/dropseed/plain/commit/d86e307))
|
|
39
|
+
- Improved README with installation instructions and table of contents ([4ebecd1](https://github.com/dropseed/plain/commit/4ebecd1))
|
|
40
|
+
- Updated package description to "Everything you need to send email in Plain" ([4ebecd1](https://github.com/dropseed/plain/commit/4ebecd1))
|
|
41
|
+
|
|
42
|
+
### Upgrade instructions
|
|
43
|
+
|
|
44
|
+
- Update your Python version to 3.13 or higher
|
|
45
|
+
|
|
46
|
+
## [0.10.2](https://github.com/dropseed/plain/releases/plain-email@0.10.2) (2025-06-23)
|
|
47
|
+
|
|
48
|
+
### What's changed
|
|
49
|
+
|
|
50
|
+
- No user-facing changes. Internal documentation and tooling updates only ([82710c3](https://github.com/dropseed/plain/commit/82710c3), [9a1963d](https://github.com/dropseed/plain/commit/9a1963d), [e1f5dd3](https://github.com/dropseed/plain/commit/e1f5dd3)).
|
|
51
|
+
|
|
52
|
+
### Upgrade instructions
|
|
53
|
+
|
|
54
|
+
- No changes required
|
plain/email/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# plain.email
|
|
2
|
+
|
|
3
|
+
**Everything you need to send email in Plain.**
|
|
4
|
+
|
|
5
|
+
- [Installation](#installation)
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
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`:
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
# settings.py
|
|
19
|
+
INSTALLED_PACKAGES = [
|
|
20
|
+
# ...
|
|
21
|
+
"plain.email",
|
|
22
|
+
]
|
|
23
|
+
```
|
plain/email/__init__.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tools for sending email.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from plain.runtime import settings
|
|
10
|
+
from plain.utils.module_loading import import_string
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .backends.base import BaseEmailBackend
|
|
14
|
+
|
|
15
|
+
from .message import (
|
|
16
|
+
DEFAULT_ATTACHMENT_MIME_TYPE,
|
|
17
|
+
BadHeaderError,
|
|
18
|
+
EmailMessage,
|
|
19
|
+
EmailMultiAlternatives,
|
|
20
|
+
SafeMIMEMultipart,
|
|
21
|
+
SafeMIMEText,
|
|
22
|
+
TemplateEmail,
|
|
23
|
+
forbid_multi_line_headers,
|
|
24
|
+
make_msgid,
|
|
25
|
+
)
|
|
26
|
+
from .utils import DNS_NAME, CachedDnsName
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"CachedDnsName",
|
|
30
|
+
"DNS_NAME",
|
|
31
|
+
"EmailMessage",
|
|
32
|
+
"EmailMultiAlternatives",
|
|
33
|
+
"TemplateEmail",
|
|
34
|
+
"SafeMIMEText",
|
|
35
|
+
"SafeMIMEMultipart",
|
|
36
|
+
"DEFAULT_ATTACHMENT_MIME_TYPE",
|
|
37
|
+
"make_msgid",
|
|
38
|
+
"BadHeaderError",
|
|
39
|
+
"forbid_multi_line_headers",
|
|
40
|
+
"get_connection",
|
|
41
|
+
"send_mail",
|
|
42
|
+
"send_mass_mail",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_connection(
|
|
47
|
+
backend: str | None = None, fail_silently: bool = False, **kwds: Any
|
|
48
|
+
) -> BaseEmailBackend:
|
|
49
|
+
"""Load an email backend and return an instance of it.
|
|
50
|
+
|
|
51
|
+
If backend is None (default), use settings.EMAIL_BACKEND.
|
|
52
|
+
|
|
53
|
+
Both fail_silently and other keyword arguments are used in the
|
|
54
|
+
constructor of the backend.
|
|
55
|
+
"""
|
|
56
|
+
klass = import_string(backend or settings.EMAIL_BACKEND)
|
|
57
|
+
return klass(fail_silently=fail_silently, **kwds)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def send_mail(
|
|
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:
|
|
71
|
+
"""
|
|
72
|
+
Easy wrapper for sending a single message to a recipient list. All members
|
|
73
|
+
of the recipient list will see the other recipients in the 'To' field.
|
|
74
|
+
|
|
75
|
+
If from_email is None, use the EMAIL_DEFAULT_FROM setting.
|
|
76
|
+
If auth_user is None, use the EMAIL_HOST_USER setting.
|
|
77
|
+
If auth_password is None, use the EMAIL_HOST_PASSWORD setting.
|
|
78
|
+
|
|
79
|
+
Note: The API for this method is frozen. New code wanting to extend the
|
|
80
|
+
functionality should use the EmailMessage class directly.
|
|
81
|
+
"""
|
|
82
|
+
connection = connection or get_connection(
|
|
83
|
+
username=auth_user,
|
|
84
|
+
password=auth_password,
|
|
85
|
+
fail_silently=fail_silently,
|
|
86
|
+
)
|
|
87
|
+
mail = EmailMultiAlternatives(
|
|
88
|
+
subject, message, from_email, recipient_list, connection=connection
|
|
89
|
+
)
|
|
90
|
+
if html_message:
|
|
91
|
+
mail.attach_alternative(html_message, "text/html")
|
|
92
|
+
|
|
93
|
+
return mail.send()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def send_mass_mail(
|
|
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:
|
|
103
|
+
"""
|
|
104
|
+
Given a datatuple of (subject, message, from_email, recipient_list), send
|
|
105
|
+
each message to each recipient list. Return the number of emails sent.
|
|
106
|
+
|
|
107
|
+
If from_email is None, use the EMAIL_DEFAULT_FROM setting.
|
|
108
|
+
If auth_user and auth_password are set, use them to log in.
|
|
109
|
+
If auth_user is None, use the EMAIL_HOST_USER setting.
|
|
110
|
+
If auth_password is None, use the EMAIL_HOST_PASSWORD setting.
|
|
111
|
+
|
|
112
|
+
Note: The API for this method is frozen. New code wanting to extend the
|
|
113
|
+
functionality should use the EmailMessage class directly.
|
|
114
|
+
"""
|
|
115
|
+
connection = connection or get_connection(
|
|
116
|
+
username=auth_user,
|
|
117
|
+
password=auth_password,
|
|
118
|
+
fail_silently=fail_silently,
|
|
119
|
+
)
|
|
120
|
+
messages = [
|
|
121
|
+
EmailMessage(subject, message, sender, recipient, connection=connection)
|
|
122
|
+
for subject, message, sender, recipient in datatuple
|
|
123
|
+
]
|
|
124
|
+
return connection.send_messages(messages)
|
|
File without changes
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Base email backend class."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
|
|
11
|
+
from ..message import EmailMessage
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BaseEmailBackend(ABC):
|
|
15
|
+
"""
|
|
16
|
+
Base class for email backend implementations.
|
|
17
|
+
|
|
18
|
+
Subclasses must at least overwrite send_messages().
|
|
19
|
+
|
|
20
|
+
open() and close() can be called indirectly by using a backend object as a
|
|
21
|
+
context manager:
|
|
22
|
+
|
|
23
|
+
with backend as connection:
|
|
24
|
+
# do something with connection
|
|
25
|
+
pass
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, fail_silently: bool = False, **kwargs: Any) -> None:
|
|
29
|
+
self.fail_silently = fail_silently
|
|
30
|
+
|
|
31
|
+
def open(self) -> bool | None:
|
|
32
|
+
"""
|
|
33
|
+
Open a network connection.
|
|
34
|
+
|
|
35
|
+
This method can be overwritten by backend implementations to
|
|
36
|
+
open a network connection.
|
|
37
|
+
|
|
38
|
+
It's up to the backend implementation to track the status of
|
|
39
|
+
a network connection if it's needed by the backend.
|
|
40
|
+
|
|
41
|
+
This method can be called by applications to force a single
|
|
42
|
+
network connection to be used when sending mails. See the
|
|
43
|
+
send_messages() method of the SMTP backend for a reference
|
|
44
|
+
implementation.
|
|
45
|
+
|
|
46
|
+
The default implementation does nothing.
|
|
47
|
+
"""
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
def close(self) -> None:
|
|
51
|
+
"""Close a network connection."""
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
def __enter__(self) -> BaseEmailBackend:
|
|
55
|
+
try:
|
|
56
|
+
self.open()
|
|
57
|
+
except Exception:
|
|
58
|
+
self.close()
|
|
59
|
+
raise
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def __exit__(
|
|
63
|
+
self,
|
|
64
|
+
exc_type: type[BaseException] | None,
|
|
65
|
+
exc_value: BaseException | None,
|
|
66
|
+
traceback: TracebackType | None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.close()
|
|
69
|
+
|
|
70
|
+
@abstractmethod
|
|
71
|
+
def send_messages(self, email_messages: list[EmailMessage]) -> int:
|
|
72
|
+
"""
|
|
73
|
+
Send one or more EmailMessage objects and return the number of email
|
|
74
|
+
messages sent.
|
|
75
|
+
"""
|
|
76
|
+
...
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Email backend that writes messages to console instead of sending them.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from .base import BaseEmailBackend
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from ..message import EmailMessage
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EmailBackend(BaseEmailBackend):
|
|
18
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
19
|
+
self.stream = kwargs.pop("stream", sys.stdout)
|
|
20
|
+
self._lock = threading.RLock()
|
|
21
|
+
super().__init__(*args, **kwargs)
|
|
22
|
+
|
|
23
|
+
def write_message(self, message: EmailMessage) -> None:
|
|
24
|
+
msg = message.message()
|
|
25
|
+
msg_data = msg.as_bytes()
|
|
26
|
+
msg_charset = msg.get_charset()
|
|
27
|
+
if msg_charset is None:
|
|
28
|
+
charset = "utf-8"
|
|
29
|
+
elif isinstance(msg_charset, str):
|
|
30
|
+
charset = msg_charset
|
|
31
|
+
else:
|
|
32
|
+
charset = msg_charset.get_output_charset() or "utf-8"
|
|
33
|
+
msg_data = msg_data.decode(charset)
|
|
34
|
+
self.stream.write(f"{msg_data}\n")
|
|
35
|
+
self.stream.write("-" * 79)
|
|
36
|
+
self.stream.write("\n")
|
|
37
|
+
|
|
38
|
+
def send_messages(self, email_messages: list[EmailMessage]) -> int:
|
|
39
|
+
"""Write all messages to the stream in a thread-safe way."""
|
|
40
|
+
if not email_messages:
|
|
41
|
+
return 0
|
|
42
|
+
msg_count = 0
|
|
43
|
+
with self._lock:
|
|
44
|
+
try:
|
|
45
|
+
stream_created = self.open()
|
|
46
|
+
for message in email_messages:
|
|
47
|
+
self.write_message(message)
|
|
48
|
+
self.stream.flush() # flush after each message
|
|
49
|
+
msg_count += 1
|
|
50
|
+
if stream_created:
|
|
51
|
+
self.close()
|
|
52
|
+
except Exception:
|
|
53
|
+
if not self.fail_silently:
|
|
54
|
+
raise
|
|
55
|
+
return msg_count
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Email backend that writes messages to a file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import os
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from plain.exceptions import ImproperlyConfigured
|
|
10
|
+
from plain.runtime import settings
|
|
11
|
+
|
|
12
|
+
from .console import EmailBackend as ConsoleEmailBackend
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from ..message import EmailMessage
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EmailBackend(ConsoleEmailBackend):
|
|
19
|
+
file_path: str # Set during __init__, validated to be non-None
|
|
20
|
+
|
|
21
|
+
def __init__(self, *args: Any, file_path: str | None = None, **kwargs: Any) -> None:
|
|
22
|
+
self._fname: str | None = None
|
|
23
|
+
_file_path: str | None = file_path or getattr(settings, "EMAIL_FILE_PATH", None)
|
|
24
|
+
if not _file_path:
|
|
25
|
+
raise ImproperlyConfigured(
|
|
26
|
+
"EMAIL_FILE_PATH must be set for the filebased email backend"
|
|
27
|
+
)
|
|
28
|
+
self.file_path = os.path.abspath(_file_path)
|
|
29
|
+
try:
|
|
30
|
+
os.makedirs(self.file_path, exist_ok=True)
|
|
31
|
+
except FileExistsError:
|
|
32
|
+
raise ImproperlyConfigured(
|
|
33
|
+
f"Path for saving email messages exists, but is not a directory: {self.file_path}"
|
|
34
|
+
)
|
|
35
|
+
except OSError as err:
|
|
36
|
+
raise ImproperlyConfigured(
|
|
37
|
+
f"Could not create directory for saving email messages: {self.file_path} ({err})"
|
|
38
|
+
)
|
|
39
|
+
# Make sure that self.file_path is writable.
|
|
40
|
+
if not os.access(self.file_path, os.W_OK):
|
|
41
|
+
raise ImproperlyConfigured(
|
|
42
|
+
f"Could not write to directory: {self.file_path}"
|
|
43
|
+
)
|
|
44
|
+
# Finally, call super().
|
|
45
|
+
# Since we're using the console-based backend as a base,
|
|
46
|
+
# force the stream to be None, so we don't default to stdout
|
|
47
|
+
kwargs["stream"] = None
|
|
48
|
+
super().__init__(*args, **kwargs)
|
|
49
|
+
|
|
50
|
+
def write_message(self, message: EmailMessage) -> None:
|
|
51
|
+
assert self.stream is not None, "stream should be opened before writing"
|
|
52
|
+
self.stream.write(message.message().as_bytes() + b"\n")
|
|
53
|
+
self.stream.write(b"-" * 79)
|
|
54
|
+
self.stream.write(b"\n")
|
|
55
|
+
|
|
56
|
+
def _get_filename(self) -> str:
|
|
57
|
+
"""Return a unique file name."""
|
|
58
|
+
if self._fname is None:
|
|
59
|
+
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
60
|
+
fname = f"{timestamp}-{abs(id(self))}.log"
|
|
61
|
+
self._fname = os.path.join(self.file_path, fname)
|
|
62
|
+
return self._fname
|
|
63
|
+
|
|
64
|
+
def open(self) -> bool:
|
|
65
|
+
if self.stream is None:
|
|
66
|
+
self.stream = open(self._get_filename(), "ab")
|
|
67
|
+
return True
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
def close(self) -> None:
|
|
71
|
+
try:
|
|
72
|
+
if self.stream is not None:
|
|
73
|
+
self.stream.close()
|
|
74
|
+
finally:
|
|
75
|
+
self.stream = None
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""SMTP email backend class."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import smtplib
|
|
6
|
+
import ssl
|
|
7
|
+
import threading
|
|
8
|
+
from functools import cached_property
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from plain.runtime import settings
|
|
12
|
+
|
|
13
|
+
from ..backends.base import BaseEmailBackend
|
|
14
|
+
from ..message import sanitize_address
|
|
15
|
+
from ..utils import DNS_NAME
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from ..message import EmailMessage
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EmailBackend(BaseEmailBackend):
|
|
22
|
+
"""
|
|
23
|
+
A wrapper that manages the SMTP network connection.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
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:
|
|
40
|
+
super().__init__(fail_silently=fail_silently)
|
|
41
|
+
self.host = host or settings.EMAIL_HOST
|
|
42
|
+
self.port = port or settings.EMAIL_PORT
|
|
43
|
+
self.username = settings.EMAIL_HOST_USER if username is None else username
|
|
44
|
+
self.password = settings.EMAIL_HOST_PASSWORD if password is None else password
|
|
45
|
+
self.use_tls = settings.EMAIL_USE_TLS if use_tls is None else use_tls
|
|
46
|
+
self.use_ssl = settings.EMAIL_USE_SSL if use_ssl is None else use_ssl
|
|
47
|
+
self.timeout = settings.EMAIL_TIMEOUT if timeout is None else timeout
|
|
48
|
+
self.ssl_keyfile = (
|
|
49
|
+
settings.EMAIL_SSL_KEYFILE if ssl_keyfile is None else ssl_keyfile
|
|
50
|
+
)
|
|
51
|
+
self.ssl_certfile = (
|
|
52
|
+
settings.EMAIL_SSL_CERTFILE if ssl_certfile is None else ssl_certfile
|
|
53
|
+
)
|
|
54
|
+
if self.use_ssl and self.use_tls:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
"EMAIL_USE_TLS/EMAIL_USE_SSL are mutually exclusive, so only set "
|
|
57
|
+
"one of those settings to True."
|
|
58
|
+
)
|
|
59
|
+
self.connection = None
|
|
60
|
+
self._lock = threading.RLock()
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def connection_class(self) -> type[smtplib.SMTP]:
|
|
64
|
+
return smtplib.SMTP_SSL if self.use_ssl else smtplib.SMTP
|
|
65
|
+
|
|
66
|
+
@cached_property
|
|
67
|
+
def ssl_context(self) -> ssl.SSLContext:
|
|
68
|
+
if self.ssl_certfile or self.ssl_keyfile:
|
|
69
|
+
ssl_context = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_CLIENT)
|
|
70
|
+
ssl_context.load_cert_chain(self.ssl_certfile, self.ssl_keyfile)
|
|
71
|
+
return ssl_context
|
|
72
|
+
else:
|
|
73
|
+
return ssl.create_default_context()
|
|
74
|
+
|
|
75
|
+
def open(self) -> bool | None:
|
|
76
|
+
"""
|
|
77
|
+
Ensure an open connection to the email server. Return whether or not a
|
|
78
|
+
new connection was required (True or False) or None if an exception
|
|
79
|
+
passed silently.
|
|
80
|
+
"""
|
|
81
|
+
if self.connection:
|
|
82
|
+
# Nothing to do if the connection is already open.
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
# If local_hostname is not specified, socket.getfqdn() gets used.
|
|
86
|
+
# For performance, we use the cached FQDN for local_hostname.
|
|
87
|
+
connection_params: dict[str, Any] = {"local_hostname": DNS_NAME.get_fqdn()}
|
|
88
|
+
if self.timeout is not None:
|
|
89
|
+
connection_params["timeout"] = self.timeout
|
|
90
|
+
if self.use_ssl:
|
|
91
|
+
connection_params["context"] = self.ssl_context
|
|
92
|
+
try:
|
|
93
|
+
self.connection = self.connection_class(
|
|
94
|
+
self.host, self.port, **connection_params
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# TLS/SSL are mutually exclusive, so only attempt TLS over
|
|
98
|
+
# non-secure connections.
|
|
99
|
+
if not self.use_ssl and self.use_tls:
|
|
100
|
+
self.connection.starttls(context=self.ssl_context)
|
|
101
|
+
if self.username and self.password:
|
|
102
|
+
self.connection.login(self.username, self.password)
|
|
103
|
+
return True
|
|
104
|
+
except OSError:
|
|
105
|
+
if not self.fail_silently:
|
|
106
|
+
raise
|
|
107
|
+
|
|
108
|
+
def close(self) -> None:
|
|
109
|
+
"""Close the connection to the email server."""
|
|
110
|
+
if self.connection is None:
|
|
111
|
+
return None
|
|
112
|
+
try:
|
|
113
|
+
try:
|
|
114
|
+
self.connection.quit()
|
|
115
|
+
except (ssl.SSLError, smtplib.SMTPServerDisconnected):
|
|
116
|
+
# This happens when calling quit() on a TLS connection
|
|
117
|
+
# sometimes, or when the connection was already disconnected
|
|
118
|
+
# by the server.
|
|
119
|
+
self.connection.close()
|
|
120
|
+
except smtplib.SMTPException:
|
|
121
|
+
if self.fail_silently:
|
|
122
|
+
return None
|
|
123
|
+
raise
|
|
124
|
+
finally:
|
|
125
|
+
self.connection = None
|
|
126
|
+
|
|
127
|
+
def send_messages(self, email_messages: list[EmailMessage]) -> int:
|
|
128
|
+
"""
|
|
129
|
+
Send one or more EmailMessage objects and return the number of email
|
|
130
|
+
messages sent.
|
|
131
|
+
"""
|
|
132
|
+
if not email_messages:
|
|
133
|
+
return 0
|
|
134
|
+
with self._lock:
|
|
135
|
+
new_conn_created = self.open()
|
|
136
|
+
if not self.connection or new_conn_created is None:
|
|
137
|
+
# We failed silently on open().
|
|
138
|
+
# Trying to send would be pointless.
|
|
139
|
+
return 0
|
|
140
|
+
num_sent = 0
|
|
141
|
+
try:
|
|
142
|
+
for message in email_messages:
|
|
143
|
+
sent = self._send(message)
|
|
144
|
+
if sent:
|
|
145
|
+
num_sent += 1
|
|
146
|
+
finally:
|
|
147
|
+
if new_conn_created:
|
|
148
|
+
self.close()
|
|
149
|
+
return num_sent
|
|
150
|
+
|
|
151
|
+
def _send(self, email_message: EmailMessage) -> bool:
|
|
152
|
+
"""A helper method that does the actual sending."""
|
|
153
|
+
if not email_message.recipients():
|
|
154
|
+
return False
|
|
155
|
+
encoding = email_message.encoding or settings.DEFAULT_CHARSET
|
|
156
|
+
from_email = sanitize_address(email_message.from_email, encoding)
|
|
157
|
+
recipients = [
|
|
158
|
+
sanitize_address(addr, encoding) for addr in email_message.recipients()
|
|
159
|
+
]
|
|
160
|
+
message = email_message.message()
|
|
161
|
+
assert self.connection is not None, "connection should be open before sending"
|
|
162
|
+
try:
|
|
163
|
+
self.connection.sendmail(
|
|
164
|
+
from_email, recipients, message.as_bytes(linesep="\r\n")
|
|
165
|
+
)
|
|
166
|
+
except smtplib.SMTPException:
|
|
167
|
+
if not self.fail_silently:
|
|
168
|
+
raise
|
|
169
|
+
return False
|
|
170
|
+
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 = None
|
|
10
|
+
|
|
11
|
+
# Host for sending email.
|
|
12
|
+
EMAIL_HOST: str = "localhost"
|
|
13
|
+
|
|
14
|
+
# Port for sending email.
|
|
15
|
+
EMAIL_PORT: int = 587
|
|
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 = True
|
|
24
|
+
EMAIL_USE_SSL: bool = False
|
|
25
|
+
EMAIL_SSL_CERTFILE: str | None = None
|
|
26
|
+
EMAIL_SSL_KEYFILE: str | None = None
|
|
27
|
+
EMAIL_TIMEOUT: int | None = None
|