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.
@@ -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
+ ```
@@ -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