plain.email 0.11.1__tar.gz → 0.13.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,15 +1,17 @@
1
1
  .venv
2
- .env
2
+ /.env
3
3
  *.egg-info
4
4
  *.py[co]
5
5
  __pycache__
6
6
  *.DS_Store
7
7
 
8
+ /*.code-workspace
9
+
8
10
  # Test apps
9
11
  plain*/tests/.plain
10
12
 
11
- # Ottobot
12
- .aider*
13
+ # Agent scratch files
14
+ /scratch
13
15
 
14
16
  # Plain temp dirs
15
17
  .plain
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.email
3
- Version: 0.11.1
3
+ Version: 0.13.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
@@ -1,5 +1,26 @@
1
1
  # plain-email changelog
2
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
+
3
24
  ## [0.11.1](https://github.com/dropseed/plain/releases/plain-email@0.11.1) (2025-10-06)
4
25
 
5
26
  ### What's changed
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from abc import ABC, abstractmethod
5
6
  from typing import TYPE_CHECKING, Any
6
7
 
7
8
  if TYPE_CHECKING:
@@ -10,7 +11,7 @@ if TYPE_CHECKING:
10
11
  from ..message import EmailMessage
11
12
 
12
13
 
13
- class BaseEmailBackend:
14
+ class BaseEmailBackend(ABC):
14
15
  """
15
16
  Base class for email backend implementations.
16
17
 
@@ -27,7 +28,7 @@ class BaseEmailBackend:
27
28
  def __init__(self, fail_silently: bool = False, **kwargs: Any) -> None:
28
29
  self.fail_silently = fail_silently
29
30
 
30
- def open(self) -> None:
31
+ def open(self) -> bool | None:
31
32
  """
32
33
  Open a network connection.
33
34
 
@@ -66,11 +67,10 @@ class BaseEmailBackend:
66
67
  ) -> None:
67
68
  self.close()
68
69
 
70
+ @abstractmethod
69
71
  def send_messages(self, email_messages: list[EmailMessage]) -> int:
70
72
  """
71
73
  Send one or more EmailMessage objects and return the number of email
72
74
  messages sent.
73
75
  """
74
- raise NotImplementedError(
75
- "subclasses of BaseEmailBackend must override send_messages() method"
76
- )
76
+ ...
@@ -23,9 +23,13 @@ class EmailBackend(BaseEmailBackend):
23
23
  def write_message(self, message: EmailMessage) -> None:
24
24
  msg = message.message()
25
25
  msg_data = msg.as_bytes()
26
- charset = (
27
- msg.get_charset().get_output_charset() if msg.get_charset() else "utf-8"
28
- )
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"
29
33
  msg_data = msg_data.decode(charset)
30
34
  self.stream.write(f"{msg_data}\n")
31
35
  self.stream.write("-" * 79)
@@ -16,13 +16,16 @@ if TYPE_CHECKING:
16
16
 
17
17
 
18
18
  class EmailBackend(ConsoleEmailBackend):
19
+ file_path: str # Set during __init__, validated to be non-None
20
+
19
21
  def __init__(self, *args: Any, file_path: str | None = None, **kwargs: Any) -> None:
20
- self._fname = None
21
- if file_path is not None:
22
- self.file_path = file_path
23
- else:
24
- self.file_path = getattr(settings, "EMAIL_FILE_PATH", None)
25
- self.file_path = os.path.abspath(self.file_path)
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)
26
29
  try:
27
30
  os.makedirs(self.file_path, exist_ok=True)
28
31
  except FileExistsError:
@@ -45,6 +48,7 @@ class EmailBackend(ConsoleEmailBackend):
45
48
  super().__init__(*args, **kwargs)
46
49
 
47
50
  def write_message(self, message: EmailMessage) -> None:
51
+ assert self.stream is not None, "stream should be opened before writing"
48
52
  self.stream.write(message.message().as_bytes() + b"\n")
49
53
  self.stream.write(b"-" * 79)
50
54
  self.stream.write(b"\n")
@@ -84,7 +84,7 @@ class EmailBackend(BaseEmailBackend):
84
84
 
85
85
  # If local_hostname is not specified, socket.getfqdn() gets used.
86
86
  # For performance, we use the cached FQDN for local_hostname.
87
- connection_params = {"local_hostname": DNS_NAME.get_fqdn()}
87
+ connection_params: dict[str, Any] = {"local_hostname": DNS_NAME.get_fqdn()}
88
88
  if self.timeout is not None:
89
89
  connection_params["timeout"] = self.timeout
90
90
  if self.use_ssl:
@@ -158,6 +158,7 @@ class EmailBackend(BaseEmailBackend):
158
158
  sanitize_address(addr, encoding) for addr in email_message.recipients()
159
159
  ]
160
160
  message = email_message.message()
161
+ assert self.connection is not None, "connection should be open before sending"
161
162
  try:
162
163
  self.connection.sendmail(
163
164
  from_email, recipients, message.as_bytes(linesep="\r\n")
@@ -181,7 +181,7 @@ class SafeMIMEText(MIMEMixin, MIMEText):
181
181
  name, val = forbid_multi_line_headers(name, val, self.encoding)
182
182
  MIMEText.__setitem__(self, name, val)
183
183
 
184
- def set_payload(
184
+ def set_payload( # type: ignore[override]
185
185
  self, payload: str, charset: str | Charset.Charset | None = None
186
186
  ) -> None:
187
187
  if charset == "utf-8" and not isinstance(charset, Charset.Charset):
@@ -349,11 +349,10 @@ class EmailMessage:
349
349
  elif content is None:
350
350
  raise ValueError("content must be provided.")
351
351
  else:
352
- mimetype = (
353
- mimetype
354
- or mimetypes.guess_type(filename)[0]
355
- or DEFAULT_ATTACHMENT_MIME_TYPE
356
- )
352
+ if filename is not None and mimetype is None:
353
+ mimetype = mimetypes.guess_type(filename)[0]
354
+ if mimetype is None:
355
+ mimetype = DEFAULT_ATTACHMENT_MIME_TYPE
357
356
  basetype, subtype = mimetype.split("/", 1)
358
357
 
359
358
  if basetype == "text":
@@ -434,12 +433,13 @@ class EmailMessage:
434
433
  else:
435
434
  # Encode non-text attachments with base64.
436
435
  attachment = MIMEBase(basetype, subtype)
436
+ assert isinstance(content, bytes), "Non-text attachments must be bytes"
437
437
  attachment.set_payload(content)
438
438
  Encoders.encode_base64(attachment)
439
439
  return attachment
440
440
 
441
441
  def _create_attachment(
442
- self, filename: str | None, content: str | bytes, mimetype: str | None = None
442
+ self, filename: str | None, content: str | bytes, mimetype: str
443
443
  ) -> SafeMIMEText | SafeMIMEMessage | MIMEBase:
444
444
  """
445
445
  Convert the filename, content, mimetype triple into a MIME attachment
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain.email"
3
- version = "0.11.1"
3
+ version = "0.13.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"
File without changes
File without changes