plain.email 0.13.0__tar.gz → 0.14.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.
@@ -0,0 +1,295 @@
1
+ Metadata-Version: 2.4
2
+ Name: plain.email
3
+ Version: 0.14.0
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
+ **Send emails from your Plain application using SMTP, console output, or file-based backends.**
15
+
16
+ - [Overview](#overview)
17
+ - [Sending a simple email](#sending-a-simple-email)
18
+ - [Sending HTML emails](#sending-html-emails)
19
+ - [Template-based emails](#template-based-emails)
20
+ - [Attachments](#attachments)
21
+ - [Configuration](#configuration)
22
+ - [SMTP settings](#smtp-settings)
23
+ - [Email backends](#email-backends)
24
+ - [SMTP backend](#smtp-backend)
25
+ - [Console backend](#console-backend)
26
+ - [File-based backend](#file-based-backend)
27
+ - [FAQs](#faqs)
28
+ - [Installation](#installation)
29
+
30
+ ## Overview
31
+
32
+ You can send emails using the `send_mail` function for simple cases, or use the `EmailMessage` and `EmailMultiAlternatives` classes for more control. For template-based emails, the `TemplateEmail` class renders HTML templates automatically.
33
+
34
+ ### Sending a simple email
35
+
36
+ The `send_mail` function is the easiest way to send an email.
37
+
38
+ ```python
39
+ from plain.email import send_mail
40
+
41
+ send_mail(
42
+ subject="Welcome!",
43
+ message="Thanks for signing up.",
44
+ from_email="hello@example.com",
45
+ recipient_list=["user@example.com"],
46
+ )
47
+ ```
48
+
49
+ To include an HTML version along with the plain text:
50
+
51
+ ```python
52
+ send_mail(
53
+ subject="Welcome!",
54
+ message="Thanks for signing up.",
55
+ from_email="hello@example.com",
56
+ recipient_list=["user@example.com"],
57
+ html_message="<h1>Thanks for signing up!</h1>",
58
+ )
59
+ ```
60
+
61
+ ### Sending HTML emails
62
+
63
+ For more control over multipart emails, use `EmailMultiAlternatives`.
64
+
65
+ ```python
66
+ from plain.email import EmailMultiAlternatives
67
+
68
+ email = EmailMultiAlternatives(
69
+ subject="Your order confirmation",
70
+ body="Your order #123 has been confirmed.",
71
+ from_email="orders@example.com",
72
+ to=["customer@example.com"],
73
+ )
74
+ email.attach_alternative("<h1>Order #123 Confirmed</h1>", "text/html")
75
+ email.send()
76
+ ```
77
+
78
+ ### Template-based emails
79
+
80
+ The `TemplateEmail` class renders emails from template files. You provide a template name, and it looks for corresponding files in your `templates/email/` directory:
81
+
82
+ - `email/{template}.html` - HTML content (required)
83
+ - `email/{template}.txt` - Plain text content (optional, falls back to stripping HTML tags)
84
+ - `email/{template}.subject.txt` - Subject line (optional)
85
+
86
+ ```python
87
+ from plain.email import TemplateEmail
88
+
89
+ email = TemplateEmail(
90
+ template="welcome",
91
+ context={"user_name": "Alice"},
92
+ to=["alice@example.com"],
93
+ )
94
+ email.send()
95
+ ```
96
+
97
+ With these template files:
98
+
99
+ ```html
100
+ <!-- templates/email/welcome.html -->
101
+ <h1>Welcome, {{ user_name }}!</h1>
102
+ <p>We're glad you're here.</p>
103
+ ```
104
+
105
+ ```text
106
+ {# templates/email/welcome.subject.txt #}
107
+ Welcome to our app, {{ user_name }}!
108
+ ```
109
+
110
+ You can subclass `TemplateEmail` to customize the template context by overriding `get_template_context()`.
111
+
112
+ ### Attachments
113
+
114
+ You can attach files to any email message.
115
+
116
+ ```python
117
+ from plain.email import EmailMessage
118
+
119
+ email = EmailMessage(
120
+ subject="Your report",
121
+ body="Please find your report attached.",
122
+ to=["user@example.com"],
123
+ )
124
+
125
+ # Attach content directly
126
+ email.attach("report.csv", csv_content, "text/csv")
127
+
128
+ # Or attach a file from disk
129
+ email.attach_file("/path/to/report.pdf")
130
+
131
+ email.send()
132
+ ```
133
+
134
+ ## Configuration
135
+
136
+ Configure email settings in your `settings.py`:
137
+
138
+ ```python
139
+ # settings.py
140
+
141
+ # Required: The backend to use for sending emails
142
+ EMAIL_BACKEND = "plain.email.backends.smtp.EmailBackend"
143
+
144
+ # Required: Default "From" address for outgoing emails
145
+ EMAIL_DEFAULT_FROM = "noreply@example.com"
146
+
147
+ # Optional: Default "Reply-To" addresses
148
+ EMAIL_DEFAULT_REPLY_TO = ["support@example.com"]
149
+ ```
150
+
151
+ ### SMTP settings
152
+
153
+ When using the SMTP backend, configure your mail server:
154
+
155
+ ```python
156
+ # settings.py
157
+
158
+ EMAIL_HOST = "smtp.example.com"
159
+ EMAIL_PORT = 587
160
+ EMAIL_HOST_USER = "your-username"
161
+ EMAIL_HOST_PASSWORD = "your-password"
162
+ EMAIL_USE_TLS = True # Use STARTTLS
163
+ EMAIL_USE_SSL = False # Use implicit SSL (mutually exclusive with TLS)
164
+
165
+ # Optional settings
166
+ EMAIL_TIMEOUT = 10 # Connection timeout in seconds
167
+ EMAIL_SSL_CERTFILE = None # Path to SSL certificate file
168
+ EMAIL_SSL_KEYFILE = None # Path to SSL key file
169
+ EMAIL_USE_LOCALTIME = False # Use local time in Date header (default: UTC)
170
+ ```
171
+
172
+ ## Email backends
173
+
174
+ The `EMAIL_BACKEND` setting controls how emails are sent. Plain includes three backends.
175
+
176
+ ### SMTP backend
177
+
178
+ The default backend sends emails via SMTP.
179
+
180
+ ```python
181
+ EMAIL_BACKEND = "plain.email.backends.smtp.EmailBackend"
182
+ ```
183
+
184
+ ### Console backend
185
+
186
+ Prints emails to the console instead of sending them. Useful during development.
187
+
188
+ ```python
189
+ EMAIL_BACKEND = "plain.email.backends.console.EmailBackend"
190
+ ```
191
+
192
+ ### File-based backend
193
+
194
+ Writes emails to files in a directory. Useful for testing and debugging.
195
+
196
+ ```python
197
+ EMAIL_BACKEND = "plain.email.backends.filebased.EmailBackend"
198
+ EMAIL_FILE_PATH = "/path/to/email-output"
199
+ ```
200
+
201
+ Each email is saved to a timestamped `.log` file in the specified directory.
202
+
203
+ ## FAQs
204
+
205
+ #### How do I send to multiple recipients efficiently?
206
+
207
+ Use `send_mass_mail` to send multiple messages over a single connection:
208
+
209
+ ```python
210
+ from plain.email import send_mass_mail
211
+
212
+ messages = (
213
+ ("Subject 1", "Body 1", "from@example.com", ["to1@example.com"]),
214
+ ("Subject 2", "Body 2", "from@example.com", ["to2@example.com"]),
215
+ )
216
+ send_mass_mail(messages)
217
+ ```
218
+
219
+ #### How do I reuse a connection for multiple emails?
220
+
221
+ Use the backend as a context manager:
222
+
223
+ ```python
224
+ from plain.email import get_connection, EmailMessage
225
+
226
+ with get_connection() as connection:
227
+ for user in users:
228
+ email = EmailMessage(
229
+ subject="Hello",
230
+ body="Hi there!",
231
+ to=[user.email],
232
+ connection=connection,
233
+ )
234
+ email.send()
235
+ ```
236
+
237
+ #### How do I add custom headers?
238
+
239
+ Pass a `headers` dict to any email class:
240
+
241
+ ```python
242
+ email = EmailMessage(
243
+ subject="Hello",
244
+ body="Content",
245
+ to=["user@example.com"],
246
+ headers={"X-Custom-Header": "value", "Reply-To": "reply@example.com"},
247
+ )
248
+ ```
249
+
250
+ #### How do I create a custom email backend?
251
+
252
+ Subclass [`BaseEmailBackend`](./backends/base.py#BaseEmailBackend) and implement the `send_messages` method:
253
+
254
+ ```python
255
+ from plain.email.backends.base import BaseEmailBackend
256
+
257
+ class MyBackend(BaseEmailBackend):
258
+ def send_messages(self, email_messages):
259
+ # Your sending logic here
260
+ return len(email_messages)
261
+ ```
262
+
263
+ ## Installation
264
+
265
+ Install the `plain.email` package from PyPI:
266
+
267
+ ```bash
268
+ uv add plain.email
269
+ ```
270
+
271
+ Add `plain.email` to your `INSTALLED_PACKAGES` and configure the required settings:
272
+
273
+ ```python
274
+ # settings.py
275
+ INSTALLED_PACKAGES = [
276
+ # ...
277
+ "plain.email",
278
+ ]
279
+
280
+ EMAIL_BACKEND = "plain.email.backends.smtp.EmailBackend"
281
+ EMAIL_DEFAULT_FROM = "noreply@example.com"
282
+
283
+ # For SMTP (adjust for your mail provider)
284
+ EMAIL_HOST = "smtp.example.com"
285
+ EMAIL_PORT = 587
286
+ EMAIL_HOST_USER = "your-username"
287
+ EMAIL_HOST_PASSWORD = "your-password"
288
+ EMAIL_USE_TLS = True
289
+ ```
290
+
291
+ For local development, use the console backend to see emails in your terminal:
292
+
293
+ ```python
294
+ EMAIL_BACKEND = "plain.email.backends.console.EmailBackend"
295
+ ```
@@ -1,5 +1,16 @@
1
1
  # plain-email changelog
2
2
 
3
+ ## [0.14.0](https://github.com/dropseed/plain/releases/plain-email@0.14.0) (2026-01-13)
4
+
5
+ ### What's changed
6
+
7
+ - Simplified public API exports to only include user-facing classes and functions ([28f4849](https://github.com/dropseed/plain/commit/28f4849ca5692acc1dbef97f1590ddbd2f5afe96))
8
+ - Improved README documentation with comprehensive usage examples and installation instructions ([da37a78](https://github.com/dropseed/plain/commit/da37a78fbb8a683c65863f4d0b7af9af5b16feec))
9
+
10
+ ### Upgrade instructions
11
+
12
+ - No changes required
13
+
3
14
  ## [0.13.0](https://github.com/dropseed/plain/releases/plain-email@0.13.0) (2025-12-04)
4
15
 
5
16
  ### What's changed
@@ -0,0 +1,284 @@
1
+ # plain.email
2
+
3
+ **Send emails from your Plain application using SMTP, console output, or file-based backends.**
4
+
5
+ - [Overview](#overview)
6
+ - [Sending a simple email](#sending-a-simple-email)
7
+ - [Sending HTML emails](#sending-html-emails)
8
+ - [Template-based emails](#template-based-emails)
9
+ - [Attachments](#attachments)
10
+ - [Configuration](#configuration)
11
+ - [SMTP settings](#smtp-settings)
12
+ - [Email backends](#email-backends)
13
+ - [SMTP backend](#smtp-backend)
14
+ - [Console backend](#console-backend)
15
+ - [File-based backend](#file-based-backend)
16
+ - [FAQs](#faqs)
17
+ - [Installation](#installation)
18
+
19
+ ## Overview
20
+
21
+ You can send emails using the `send_mail` function for simple cases, or use the `EmailMessage` and `EmailMultiAlternatives` classes for more control. For template-based emails, the `TemplateEmail` class renders HTML templates automatically.
22
+
23
+ ### Sending a simple email
24
+
25
+ The `send_mail` function is the easiest way to send an email.
26
+
27
+ ```python
28
+ from plain.email import send_mail
29
+
30
+ send_mail(
31
+ subject="Welcome!",
32
+ message="Thanks for signing up.",
33
+ from_email="hello@example.com",
34
+ recipient_list=["user@example.com"],
35
+ )
36
+ ```
37
+
38
+ To include an HTML version along with the plain text:
39
+
40
+ ```python
41
+ send_mail(
42
+ subject="Welcome!",
43
+ message="Thanks for signing up.",
44
+ from_email="hello@example.com",
45
+ recipient_list=["user@example.com"],
46
+ html_message="<h1>Thanks for signing up!</h1>",
47
+ )
48
+ ```
49
+
50
+ ### Sending HTML emails
51
+
52
+ For more control over multipart emails, use `EmailMultiAlternatives`.
53
+
54
+ ```python
55
+ from plain.email import EmailMultiAlternatives
56
+
57
+ email = EmailMultiAlternatives(
58
+ subject="Your order confirmation",
59
+ body="Your order #123 has been confirmed.",
60
+ from_email="orders@example.com",
61
+ to=["customer@example.com"],
62
+ )
63
+ email.attach_alternative("<h1>Order #123 Confirmed</h1>", "text/html")
64
+ email.send()
65
+ ```
66
+
67
+ ### Template-based emails
68
+
69
+ The `TemplateEmail` class renders emails from template files. You provide a template name, and it looks for corresponding files in your `templates/email/` directory:
70
+
71
+ - `email/{template}.html` - HTML content (required)
72
+ - `email/{template}.txt` - Plain text content (optional, falls back to stripping HTML tags)
73
+ - `email/{template}.subject.txt` - Subject line (optional)
74
+
75
+ ```python
76
+ from plain.email import TemplateEmail
77
+
78
+ email = TemplateEmail(
79
+ template="welcome",
80
+ context={"user_name": "Alice"},
81
+ to=["alice@example.com"],
82
+ )
83
+ email.send()
84
+ ```
85
+
86
+ With these template files:
87
+
88
+ ```html
89
+ <!-- templates/email/welcome.html -->
90
+ <h1>Welcome, {{ user_name }}!</h1>
91
+ <p>We're glad you're here.</p>
92
+ ```
93
+
94
+ ```text
95
+ {# templates/email/welcome.subject.txt #}
96
+ Welcome to our app, {{ user_name }}!
97
+ ```
98
+
99
+ You can subclass `TemplateEmail` to customize the template context by overriding `get_template_context()`.
100
+
101
+ ### Attachments
102
+
103
+ You can attach files to any email message.
104
+
105
+ ```python
106
+ from plain.email import EmailMessage
107
+
108
+ email = EmailMessage(
109
+ subject="Your report",
110
+ body="Please find your report attached.",
111
+ to=["user@example.com"],
112
+ )
113
+
114
+ # Attach content directly
115
+ email.attach("report.csv", csv_content, "text/csv")
116
+
117
+ # Or attach a file from disk
118
+ email.attach_file("/path/to/report.pdf")
119
+
120
+ email.send()
121
+ ```
122
+
123
+ ## Configuration
124
+
125
+ Configure email settings in your `settings.py`:
126
+
127
+ ```python
128
+ # settings.py
129
+
130
+ # Required: The backend to use for sending emails
131
+ EMAIL_BACKEND = "plain.email.backends.smtp.EmailBackend"
132
+
133
+ # Required: Default "From" address for outgoing emails
134
+ EMAIL_DEFAULT_FROM = "noreply@example.com"
135
+
136
+ # Optional: Default "Reply-To" addresses
137
+ EMAIL_DEFAULT_REPLY_TO = ["support@example.com"]
138
+ ```
139
+
140
+ ### SMTP settings
141
+
142
+ When using the SMTP backend, configure your mail server:
143
+
144
+ ```python
145
+ # settings.py
146
+
147
+ EMAIL_HOST = "smtp.example.com"
148
+ EMAIL_PORT = 587
149
+ EMAIL_HOST_USER = "your-username"
150
+ EMAIL_HOST_PASSWORD = "your-password"
151
+ EMAIL_USE_TLS = True # Use STARTTLS
152
+ EMAIL_USE_SSL = False # Use implicit SSL (mutually exclusive with TLS)
153
+
154
+ # Optional settings
155
+ EMAIL_TIMEOUT = 10 # Connection timeout in seconds
156
+ EMAIL_SSL_CERTFILE = None # Path to SSL certificate file
157
+ EMAIL_SSL_KEYFILE = None # Path to SSL key file
158
+ EMAIL_USE_LOCALTIME = False # Use local time in Date header (default: UTC)
159
+ ```
160
+
161
+ ## Email backends
162
+
163
+ The `EMAIL_BACKEND` setting controls how emails are sent. Plain includes three backends.
164
+
165
+ ### SMTP backend
166
+
167
+ The default backend sends emails via SMTP.
168
+
169
+ ```python
170
+ EMAIL_BACKEND = "plain.email.backends.smtp.EmailBackend"
171
+ ```
172
+
173
+ ### Console backend
174
+
175
+ Prints emails to the console instead of sending them. Useful during development.
176
+
177
+ ```python
178
+ EMAIL_BACKEND = "plain.email.backends.console.EmailBackend"
179
+ ```
180
+
181
+ ### File-based backend
182
+
183
+ Writes emails to files in a directory. Useful for testing and debugging.
184
+
185
+ ```python
186
+ EMAIL_BACKEND = "plain.email.backends.filebased.EmailBackend"
187
+ EMAIL_FILE_PATH = "/path/to/email-output"
188
+ ```
189
+
190
+ Each email is saved to a timestamped `.log` file in the specified directory.
191
+
192
+ ## FAQs
193
+
194
+ #### How do I send to multiple recipients efficiently?
195
+
196
+ Use `send_mass_mail` to send multiple messages over a single connection:
197
+
198
+ ```python
199
+ from plain.email import send_mass_mail
200
+
201
+ messages = (
202
+ ("Subject 1", "Body 1", "from@example.com", ["to1@example.com"]),
203
+ ("Subject 2", "Body 2", "from@example.com", ["to2@example.com"]),
204
+ )
205
+ send_mass_mail(messages)
206
+ ```
207
+
208
+ #### How do I reuse a connection for multiple emails?
209
+
210
+ Use the backend as a context manager:
211
+
212
+ ```python
213
+ from plain.email import get_connection, EmailMessage
214
+
215
+ with get_connection() as connection:
216
+ for user in users:
217
+ email = EmailMessage(
218
+ subject="Hello",
219
+ body="Hi there!",
220
+ to=[user.email],
221
+ connection=connection,
222
+ )
223
+ email.send()
224
+ ```
225
+
226
+ #### How do I add custom headers?
227
+
228
+ Pass a `headers` dict to any email class:
229
+
230
+ ```python
231
+ email = EmailMessage(
232
+ subject="Hello",
233
+ body="Content",
234
+ to=["user@example.com"],
235
+ headers={"X-Custom-Header": "value", "Reply-To": "reply@example.com"},
236
+ )
237
+ ```
238
+
239
+ #### How do I create a custom email backend?
240
+
241
+ Subclass [`BaseEmailBackend`](./backends/base.py#BaseEmailBackend) and implement the `send_messages` method:
242
+
243
+ ```python
244
+ from plain.email.backends.base import BaseEmailBackend
245
+
246
+ class MyBackend(BaseEmailBackend):
247
+ def send_messages(self, email_messages):
248
+ # Your sending logic here
249
+ return len(email_messages)
250
+ ```
251
+
252
+ ## Installation
253
+
254
+ Install the `plain.email` package from PyPI:
255
+
256
+ ```bash
257
+ uv add plain.email
258
+ ```
259
+
260
+ Add `plain.email` to your `INSTALLED_PACKAGES` and configure the required settings:
261
+
262
+ ```python
263
+ # settings.py
264
+ INSTALLED_PACKAGES = [
265
+ # ...
266
+ "plain.email",
267
+ ]
268
+
269
+ EMAIL_BACKEND = "plain.email.backends.smtp.EmailBackend"
270
+ EMAIL_DEFAULT_FROM = "noreply@example.com"
271
+
272
+ # For SMTP (adjust for your mail provider)
273
+ EMAIL_HOST = "smtp.example.com"
274
+ EMAIL_PORT = 587
275
+ EMAIL_HOST_USER = "your-username"
276
+ EMAIL_HOST_PASSWORD = "your-password"
277
+ EMAIL_USE_TLS = True
278
+ ```
279
+
280
+ For local development, use the console backend to see emails in your terminal:
281
+
282
+ ```python
283
+ EMAIL_BACKEND = "plain.email.backends.console.EmailBackend"
284
+ ```
@@ -13,30 +13,19 @@ if TYPE_CHECKING:
13
13
  from .backends.base import BaseEmailBackend
14
14
 
15
15
  from .message import (
16
- DEFAULT_ATTACHMENT_MIME_TYPE,
17
16
  BadHeaderError,
18
17
  EmailMessage,
19
18
  EmailMultiAlternatives,
20
- SafeMIMEMultipart,
21
- SafeMIMEText,
22
19
  TemplateEmail,
23
- forbid_multi_line_headers,
24
20
  make_msgid,
25
21
  )
26
- from .utils import DNS_NAME, CachedDnsName
27
22
 
28
23
  __all__ = [
29
- "CachedDnsName",
30
- "DNS_NAME",
31
24
  "EmailMessage",
32
25
  "EmailMultiAlternatives",
33
26
  "TemplateEmail",
34
- "SafeMIMEText",
35
- "SafeMIMEMultipart",
36
- "DEFAULT_ATTACHMENT_MIME_TYPE",
37
27
  "make_msgid",
38
28
  "BadHeaderError",
39
- "forbid_multi_line_headers",
40
29
  "get_connection",
41
30
  "send_mail",
42
31
  "send_mass_mail",
@@ -11,8 +11,8 @@ from typing import TYPE_CHECKING, Any
11
11
  from plain.runtime import settings
12
12
 
13
13
  from ..backends.base import BaseEmailBackend
14
- from ..message import sanitize_address
15
- from ..utils import DNS_NAME
14
+ from ..message import _sanitize_address
15
+ from ..utils import _DNS_NAME
16
16
 
17
17
  if TYPE_CHECKING:
18
18
  from ..message import EmailMessage
@@ -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: dict[str, Any] = {"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:
@@ -153,9 +153,9 @@ class EmailBackend(BaseEmailBackend):
153
153
  if not email_message.recipients():
154
154
  return False
155
155
  encoding = email_message.encoding or settings.DEFAULT_CHARSET
156
- from_email = sanitize_address(email_message.from_email, encoding)
156
+ from_email = _sanitize_address(email_message.from_email, encoding)
157
157
  recipients = [
158
- sanitize_address(addr, encoding) for addr in email_message.recipients()
158
+ _sanitize_address(addr, encoding) for addr in email_message.recipients()
159
159
  ]
160
160
  message = email_message.message()
161
161
  assert self.connection is not None, "connection should be open before sending"
@@ -20,12 +20,13 @@ from io import BytesIO, StringIO
20
20
  from pathlib import Path
21
21
  from typing import TYPE_CHECKING, Any
22
22
 
23
+ from plain.internal import internalcode
23
24
  from plain.runtime import settings
24
25
  from plain.templates import Template, TemplateFileMissing
25
26
  from plain.utils.encoding import force_str, punycode
26
27
  from plain.utils.html import strip_tags
27
28
 
28
- from .utils import DNS_NAME
29
+ from .utils import _DNS_NAME
29
30
 
30
31
  if TYPE_CHECKING:
31
32
  from os import PathLike
@@ -34,16 +35,16 @@ if TYPE_CHECKING:
34
35
 
35
36
  # Don't BASE64-encode UTF-8 messages so that we avoid unwanted attention from
36
37
  # some spam filters.
37
- utf8_charset = Charset.Charset("utf-8")
38
- utf8_charset.body_encoding = None # type: ignore[assignment] # Python defaults to BASE64
39
- utf8_charset_qp = Charset.Charset("utf-8")
40
- utf8_charset_qp.body_encoding = Charset.QP
38
+ _utf8_charset = Charset.Charset("utf-8")
39
+ _utf8_charset.body_encoding = None # type: ignore[assignment] # Python defaults to BASE64
40
+ _utf8_charset_qp = Charset.Charset("utf-8")
41
+ _utf8_charset_qp.body_encoding = Charset.QP
41
42
 
42
43
  # Default MIME type to use on attachments (if it is not explicitly given
43
44
  # and cannot be guessed).
44
- DEFAULT_ATTACHMENT_MIME_TYPE = "application/octet-stream"
45
+ _DEFAULT_ATTACHMENT_MIME_TYPE = "application/octet-stream"
45
46
 
46
- RFC5322_EMAIL_LINE_LENGTH_LIMIT = 998
47
+ _RFC5322_EMAIL_LINE_LENGTH_LIMIT = 998
47
48
 
48
49
 
49
50
  class BadHeaderError(ValueError):
@@ -51,7 +52,7 @@ class BadHeaderError(ValueError):
51
52
 
52
53
 
53
54
  # Header names that contain structured address data (RFC 5322).
54
- ADDRESS_HEADERS = {
55
+ _ADDRESS_HEADERS = {
55
56
  "from",
56
57
  "sender",
57
58
  "reply-to",
@@ -66,7 +67,7 @@ ADDRESS_HEADERS = {
66
67
  }
67
68
 
68
69
 
69
- def forbid_multi_line_headers(
70
+ def _forbid_multi_line_headers(
70
71
  name: str, val: str, encoding: str | None
71
72
  ) -> tuple[str, str]:
72
73
  """Forbid multi-line headers to prevent header injection."""
@@ -79,9 +80,9 @@ def forbid_multi_line_headers(
79
80
  try:
80
81
  val.encode("ascii")
81
82
  except UnicodeEncodeError:
82
- if name.lower() in ADDRESS_HEADERS:
83
+ if name.lower() in _ADDRESS_HEADERS:
83
84
  val = ", ".join(
84
- sanitize_address(addr, encoding) for addr in getaddresses((val,))
85
+ _sanitize_address(addr, encoding) for addr in getaddresses((val,))
85
86
  )
86
87
  else:
87
88
  val = Header(val, encoding).encode()
@@ -91,7 +92,7 @@ def forbid_multi_line_headers(
91
92
  return name, val
92
93
 
93
94
 
94
- def sanitize_address(addr: str | tuple[str, str], encoding: str) -> str:
95
+ def _sanitize_address(addr: str | tuple[str, str], encoding: str) -> str:
95
96
  """
96
97
  Format a pair of (name, address) or an email address string.
97
98
  """
@@ -135,6 +136,7 @@ def sanitize_address(addr: str | tuple[str, str], encoding: str) -> str:
135
136
  return formataddr((nm, parsed_address.addr_spec))
136
137
 
137
138
 
139
+ @internalcode
138
140
  class MIMEMixin:
139
141
  def as_string(self, unixfrom: bool = False, linesep: str = "\n") -> str:
140
142
  """Return the entire formatted message as a string.
@@ -163,13 +165,15 @@ class MIMEMixin:
163
165
  return fp.getvalue()
164
166
 
165
167
 
168
+ @internalcode
166
169
  class SafeMIMEMessage(MIMEMixin, MIMEMessage):
167
170
  def __setitem__(self, name: str, val: str) -> None:
168
171
  # message/rfc822 attachments must be ASCII
169
- name, val = forbid_multi_line_headers(name, val, "ascii")
172
+ name, val = _forbid_multi_line_headers(name, val, "ascii")
170
173
  MIMEMessage.__setitem__(self, name, val)
171
174
 
172
175
 
176
+ @internalcode
173
177
  class SafeMIMEText(MIMEMixin, MIMEText):
174
178
  def __init__(
175
179
  self, _text: str, _subtype: str = "plain", _charset: str | None = None
@@ -178,7 +182,7 @@ class SafeMIMEText(MIMEMixin, MIMEText):
178
182
  MIMEText.__init__(self, _text, _subtype=_subtype, _charset=_charset)
179
183
 
180
184
  def __setitem__(self, name: str, val: str) -> None:
181
- name, val = forbid_multi_line_headers(name, val, self.encoding)
185
+ name, val = _forbid_multi_line_headers(name, val, self.encoding)
182
186
  MIMEText.__setitem__(self, name, val)
183
187
 
184
188
  def set_payload( # type: ignore[override]
@@ -186,15 +190,16 @@ class SafeMIMEText(MIMEMixin, MIMEText):
186
190
  ) -> None:
187
191
  if charset == "utf-8" and not isinstance(charset, Charset.Charset):
188
192
  has_long_lines = any(
189
- len(line.encode()) > RFC5322_EMAIL_LINE_LENGTH_LIMIT
193
+ len(line.encode()) > _RFC5322_EMAIL_LINE_LENGTH_LIMIT
190
194
  for line in payload.splitlines()
191
195
  )
192
196
  # Quoted-Printable encoding has the side effect of shortening long
193
197
  # lines, if any (#22561).
194
- charset = utf8_charset_qp if has_long_lines else utf8_charset
198
+ charset = _utf8_charset_qp if has_long_lines else _utf8_charset
195
199
  MIMEText.set_payload(self, payload, charset=charset)
196
200
 
197
201
 
202
+ @internalcode
198
203
  class SafeMIMEMultipart(MIMEMixin, MIMEMultipart):
199
204
  def __init__(
200
205
  self,
@@ -208,7 +213,7 @@ class SafeMIMEMultipart(MIMEMixin, MIMEMultipart):
208
213
  MIMEMultipart.__init__(self, _subtype, boundary, _subparts, **_params)
209
214
 
210
215
  def __setitem__(self, name: str, val: str) -> None:
211
- name, val = forbid_multi_line_headers(name, val, self.encoding)
216
+ name, val = _forbid_multi_line_headers(name, val, self.encoding)
212
217
  MIMEMultipart.__setitem__(self, name, val)
213
218
 
214
219
 
@@ -300,8 +305,8 @@ class EmailMessage:
300
305
  # will get picked up by formatdate().
301
306
  msg["Date"] = formatdate(localtime=settings.EMAIL_USE_LOCALTIME)
302
307
  if "message-id" not in header_names:
303
- # Use cached DNS_NAME for performance
304
- msg["Message-ID"] = make_msgid(domain=str(DNS_NAME))
308
+ # Use cached _DNS_NAME for performance
309
+ msg["Message-ID"] = make_msgid(domain=str(_DNS_NAME))
305
310
  for name, value in self.extra_headers.items():
306
311
  if name.lower() != "from": # From is already handled
307
312
  msg[name] = value
@@ -337,7 +342,7 @@ class EmailMessage:
337
342
 
338
343
  For a text/* mimetype (guessed or specified), when a bytes object is
339
344
  specified as content, decode it as UTF-8. If that fails, set the
340
- mimetype to DEFAULT_ATTACHMENT_MIME_TYPE and don't decode the content.
345
+ mimetype to _DEFAULT_ATTACHMENT_MIME_TYPE and don't decode the content.
341
346
  """
342
347
  if isinstance(filename, MIMEBase):
343
348
  if content is not None or mimetype is not None:
@@ -352,7 +357,7 @@ class EmailMessage:
352
357
  if filename is not None and mimetype is None:
353
358
  mimetype = mimetypes.guess_type(filename)[0]
354
359
  if mimetype is None:
355
- mimetype = DEFAULT_ATTACHMENT_MIME_TYPE
360
+ mimetype = _DEFAULT_ATTACHMENT_MIME_TYPE
356
361
  basetype, subtype = mimetype.split("/", 1)
357
362
 
358
363
  if basetype == "text":
@@ -362,7 +367,7 @@ class EmailMessage:
362
367
  except UnicodeDecodeError:
363
368
  # If mimetype suggests the file is text but it's
364
369
  # actually binary, read() raises a UnicodeDecodeError.
365
- mimetype = DEFAULT_ATTACHMENT_MIME_TYPE
370
+ mimetype = _DEFAULT_ATTACHMENT_MIME_TYPE
366
371
 
367
372
  self.attachments.append((filename, content, mimetype))
368
373
 
@@ -372,12 +377,12 @@ class EmailMessage:
372
377
  """
373
378
  Attach a file from the filesystem.
374
379
 
375
- Set the mimetype to DEFAULT_ATTACHMENT_MIME_TYPE if it isn't specified
380
+ Set the mimetype to _DEFAULT_ATTACHMENT_MIME_TYPE if it isn't specified
376
381
  and cannot be guessed.
377
382
 
378
383
  For a text/* mimetype (guessed or specified), decode the file's content
379
384
  as UTF-8. If that fails, set the mimetype to
380
- DEFAULT_ATTACHMENT_MIME_TYPE and don't decode the content.
385
+ _DEFAULT_ATTACHMENT_MIME_TYPE and don't decode the content.
381
386
  """
382
387
  path = Path(path)
383
388
  with path.open("rb") as file:
@@ -6,11 +6,13 @@ from __future__ import annotations
6
6
 
7
7
  import socket
8
8
 
9
+ from plain.internal import internalcode
9
10
  from plain.utils.encoding import punycode
10
11
 
11
12
 
12
13
  # Cache the hostname, but do it lazily: socket.getfqdn() can take a couple of
13
14
  # seconds, which slows down the restart of the server.
15
+ @internalcode
14
16
  class CachedDnsName:
15
17
  def __str__(self) -> str:
16
18
  return self.get_fqdn()
@@ -21,4 +23,4 @@ class CachedDnsName:
21
23
  return self._fqdn
22
24
 
23
25
 
24
- DNS_NAME = CachedDnsName()
26
+ _DNS_NAME = CachedDnsName()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain.email"
3
- version = "0.13.0"
3
+ version = "0.14.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,34 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: plain.email
3
- Version: 0.13.0
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
- ```
@@ -1,23 +0,0 @@
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
- ```
File without changes
File without changes
File without changes