plain.email 0.11.0__tar.gz → 0.12.0__tar.gz

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.
@@ -1,5 +1,5 @@
1
1
  .venv
2
- .env
2
+ /.env
3
3
  *.egg-info
4
4
  *.py[co]
5
5
  __pycache__
@@ -8,10 +8,12 @@ __pycache__
8
8
  # Test apps
9
9
  plain*/tests/.plain
10
10
 
11
- # Ottobot
12
- .aider*
11
+ # Agent scratch files
12
+ /scratch
13
13
 
14
14
  # Plain temp dirs
15
15
  .plain
16
16
 
17
17
  .vscode
18
+ /.claude
19
+ /.benchmarks
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.email
3
- Version: 0.11.0
3
+ Version: 0.12.0
4
4
  Summary: Everything you need to send email in Plain.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-Expression: BSD-3-Clause
@@ -0,0 +1,44 @@
1
+ # plain-email changelog
2
+
3
+ ## [0.12.0](https://github.com/dropseed/plain/releases/plain-email@0.12.0) (2025-11-12)
4
+
5
+ ### What's changed
6
+
7
+ - 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))
8
+ - `BaseEmailBackend` now uses Python's abstract base class with `@abstractmethod` for better type checking ([245b5f4](https://github.com/dropseed/plain/commit/245b5f472c89178b8b764869f1624f8fc885b0f7))
9
+
10
+ ### Upgrade instructions
11
+
12
+ - If using the filebased email backend, ensure `EMAIL_FILE_PATH` is configured in your settings or passed when initializing the backend
13
+
14
+ ## [0.11.1](https://github.com/dropseed/plain/releases/plain-email@0.11.1) (2025-10-06)
15
+
16
+ ### What's changed
17
+
18
+ - Added comprehensive type annotations throughout the package for improved IDE support and type checking ([5a32120](https://github.com/dropseed/plain/commit/5a3212020c473d3a10763cedd0b0b7ca778911de))
19
+
20
+ ### Upgrade instructions
21
+
22
+ - No changes required
23
+
24
+ ## [0.11.0](https://github.com/dropseed/plain/releases/plain-email@0.11.0) (2025-09-19)
25
+
26
+ ### What's changed
27
+
28
+ - Updated Python minimum requirement to 3.13 ([d86e307](https://github.com/dropseed/plain/commit/d86e307))
29
+ - Improved README with installation instructions and table of contents ([4ebecd1](https://github.com/dropseed/plain/commit/4ebecd1))
30
+ - Updated package description to "Everything you need to send email in Plain" ([4ebecd1](https://github.com/dropseed/plain/commit/4ebecd1))
31
+
32
+ ### Upgrade instructions
33
+
34
+ - Update your Python version to 3.13 or higher
35
+
36
+ ## [0.10.2](https://github.com/dropseed/plain/releases/plain-email@0.10.2) (2025-06-23)
37
+
38
+ ### What's changed
39
+
40
+ - 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)).
41
+
42
+ ### Upgrade instructions
43
+
44
+ - No changes required
@@ -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(backend=None, fail_silently=False, **kwds):
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, fail_silently=False, auth_user=None, auth_password=None, connection=None
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.
@@ -1,7 +1,17 @@
1
1
  """Base email backend class."""
2
2
 
3
+ from __future__ import annotations
3
4
 
4
- class BaseEmailBackend:
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):
5
15
  """
6
16
  Base class for email backend implementations.
7
17
 
@@ -15,10 +25,10 @@ class BaseEmailBackend:
15
25
  pass
16
26
  """
17
27
 
18
- def __init__(self, fail_silently=False, **kwargs):
28
+ def __init__(self, fail_silently: bool = False, **kwargs: Any) -> None:
19
29
  self.fail_silently = fail_silently
20
30
 
21
- def open(self):
31
+ def open(self) -> None:
22
32
  """
23
33
  Open a network connection.
24
34
 
@@ -37,11 +47,11 @@ class BaseEmailBackend:
37
47
  """
38
48
  pass
39
49
 
40
- def close(self):
50
+ def close(self) -> None:
41
51
  """Close a network connection."""
42
52
  pass
43
53
 
44
- def __enter__(self):
54
+ def __enter__(self) -> BaseEmailBackend:
45
55
  try:
46
56
  self.open()
47
57
  except Exception:
@@ -49,14 +59,18 @@ class BaseEmailBackend:
49
59
  raise
50
60
  return self
51
61
 
52
- def __exit__(self, exc_type, exc_value, traceback):
62
+ def __exit__(
63
+ self,
64
+ exc_type: type[BaseException] | None,
65
+ exc_value: BaseException | None,
66
+ traceback: TracebackType | None,
67
+ ) -> None:
53
68
  self.close()
54
69
 
55
- def send_messages(self, email_messages):
70
+ @abstractmethod
71
+ def send_messages(self, email_messages: list[EmailMessage]) -> int:
56
72
  """
57
73
  Send one or more EmailMessage objects and return the number of email
58
74
  messages sent.
59
75
  """
60
- raise NotImplementedError(
61
- "subclasses of BaseEmailBackend must override send_messages() method"
62
- )
76
+ ...
@@ -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,21 +1,31 @@
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
17
23
  else:
18
24
  self.file_path = getattr(settings, "EMAIL_FILE_PATH", None)
25
+ if not self.file_path:
26
+ raise ImproperlyConfigured(
27
+ "EMAIL_FILE_PATH must be set for the filebased email backend"
28
+ )
19
29
  self.file_path = os.path.abspath(self.file_path)
20
30
  try:
21
31
  os.makedirs(self.file_path, exist_ok=True)
@@ -38,12 +48,13 @@ class EmailBackend(ConsoleEmailBackend):
38
48
  kwargs["stream"] = None
39
49
  super().__init__(*args, **kwargs)
40
50
 
41
- def write_message(self, message):
51
+ def write_message(self, message: EmailMessage) -> None:
52
+ assert self.stream is not None, "stream should be opened before writing"
42
53
  self.stream.write(message.message().as_bytes() + b"\n")
43
54
  self.stream.write(b"-" * 79)
44
55
  self.stream.write(b"\n")
45
56
 
46
- def _get_filename(self):
57
+ def _get_filename(self) -> str:
47
58
  """Return a unique file name."""
48
59
  if self._fname is None:
49
60
  timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
@@ -51,13 +62,13 @@ class EmailBackend(ConsoleEmailBackend):
51
62
  self._fname = os.path.join(self.file_path, fname)
52
63
  return self._fname
53
64
 
54
- def open(self):
65
+ def open(self) -> bool:
55
66
  if self.stream is None:
56
67
  self.stream = open(self._get_filename(), "ab")
57
68
  return True
58
69
  return False
59
70
 
60
- def close(self):
71
+ def close(self) -> None:
61
72
  try:
62
73
  if self.stream is not None:
63
74
  self.stream.close()
@@ -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
@@ -78,7 +84,7 @@ class EmailBackend(BaseEmailBackend):
78
84
 
79
85
  # If local_hostname is not specified, socket.getfqdn() gets used.
80
86
  # For performance, we use the cached FQDN for local_hostname.
81
- connection_params = {"local_hostname": DNS_NAME.get_fqdn()}
87
+ connection_params: dict[str, Any] = {"local_hostname": DNS_NAME.get_fqdn()}
82
88
  if self.timeout is not None:
83
89
  connection_params["timeout"] = self.timeout
84
90
  if self.use_ssl:
@@ -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
@@ -152,6 +158,7 @@ class EmailBackend(BaseEmailBackend):
152
158
  sanitize_address(addr, encoding) for addr in email_message.recipients()
153
159
  ]
154
160
  message = email_message.message()
161
+ assert self.connection is not None, "connection should be open before sending"
155
162
  try:
156
163
  self.connection.sendmail(
157
164
  from_email, recipients, message.as_bytes(linesep="\r\n")
@@ -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
@@ -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, parser
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(name, val, encoding):
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 = parser.get_mailbox(addr)
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__(self, _text, _subtype="plain", _charset=None):
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(self, payload, charset=None):
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, _subtype="mixed", boundary=None, _subparts=None, encoding=None, **_params
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(self, filename=None, content=None, mimetype=None):
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(self, path, mimetype=None):
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(self, msg):
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(self, content, mimetype):
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(self, filename, content, mimetype=None):
441
+ def _create_attachment(
442
+ self, filename: str | None, content: str | bytes, mimetype: str
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
- filename = ("utf-8", "", filename)
454
+ encoded_filename = ("utf-8", "", filename)
417
455
  attachment.add_header(
418
- "Content-Disposition", "attachment", filename=filename
456
+ "Content-Disposition", "attachment", filename=encoded_filename
419
457
  )
420
458
  return attachment
421
459
 
422
- def _set_list_header_if_not_empty(self, msg, header, values):
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(self, msg):
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"
@@ -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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain.email"
3
- version = "0.11.0"
3
+ version = "0.12.0"
4
4
  description = "Everything you need to send email in Plain."
5
5
  authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}]
6
6
  license = "BSD-3-Clause"
@@ -1,23 +0,0 @@
1
- # plain-email changelog
2
-
3
- ## [0.11.0](https://github.com/dropseed/plain/releases/plain-email@0.11.0) (2025-09-19)
4
-
5
- ### What's changed
6
-
7
- - Updated Python minimum requirement to 3.13 ([d86e307](https://github.com/dropseed/plain/commit/d86e307))
8
- - Improved README with installation instructions and table of contents ([4ebecd1](https://github.com/dropseed/plain/commit/4ebecd1))
9
- - Updated package description to "Everything you need to send email in Plain" ([4ebecd1](https://github.com/dropseed/plain/commit/4ebecd1))
10
-
11
- ### Upgrade instructions
12
-
13
- - Update your Python version to 3.13 or higher
14
-
15
- ## [0.10.2](https://github.com/dropseed/plain/releases/plain-email@0.10.2) (2025-06-23)
16
-
17
- ### What's changed
18
-
19
- - 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)).
20
-
21
- ### Upgrade instructions
22
-
23
- - No changes required
File without changes
File without changes