xtr-mime 6.0.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.
- xtr_mime-6.0.0/LICENSE +22 -0
- xtr_mime-6.0.0/PKG-INFO +446 -0
- xtr_mime-6.0.0/README.md +416 -0
- xtr_mime-6.0.0/pyproject.toml +184 -0
- xtr_mime-6.0.0/pyproject.toml.orig +192 -0
- xtr_mime-6.0.0/src/xtr_mime/.agents/skills/xtr-mime/SKILL.md +149 -0
- xtr_mime-6.0.0/src/xtr_mime/.agents/skills/xtr-mime/references/application.md +105 -0
- xtr_mime-6.0.0/src/xtr_mime/.agents/skills/xtr-mime/references/limitations.md +23 -0
- xtr_mime-6.0.0/src/xtr_mime/__init__.py +122 -0
- xtr_mime-6.0.0/src/xtr_mime/_display_name.py +38 -0
- xtr_mime-6.0.0/src/xtr_mime/_email_body.py +126 -0
- xtr_mime-6.0.0/src/xtr_mime/_email_headers.py +82 -0
- xtr_mime-6.0.0/src/xtr_mime/_engine/__init__.py +115 -0
- xtr_mime-6.0.0/src/xtr_mime/_engine/_address_list.py +136 -0
- xtr_mime-6.0.0/src/xtr_mime/_engine/_addresses.py +103 -0
- xtr_mime-6.0.0/src/xtr_mime/_engine/_build.py +216 -0
- xtr_mime-6.0.0/src/xtr_mime/_engine/_fold.py +166 -0
- xtr_mime-6.0.0/src/xtr_mime/_engine/_grammar.py +166 -0
- xtr_mime-6.0.0/src/xtr_mime/_engine/_parameters.py +104 -0
- xtr_mime-6.0.0/src/xtr_mime/_engine/_policy.py +214 -0
- xtr_mime-6.0.0/src/xtr_mime/_engine/_render.py +141 -0
- xtr_mime-6.0.0/src/xtr_mime/_engine/_unstructured.py +84 -0
- xtr_mime-6.0.0/src/xtr_mime/_engine/_values.py +108 -0
- xtr_mime-6.0.0/src/xtr_mime/_error_text.py +66 -0
- xtr_mime-6.0.0/src/xtr_mime/_header_reader.py +230 -0
- xtr_mime-6.0.0/src/xtr_mime/_html_references.py +79 -0
- xtr_mime-6.0.0/src/xtr_mime/_readable_file.py +24 -0
- xtr_mime-6.0.0/src/xtr_mime/address.py +265 -0
- xtr_mime-6.0.0/src/xtr_mime/body_renderer_interface.py +23 -0
- xtr_mime-6.0.0/src/xtr_mime/bundle/__init__.py +14 -0
- xtr_mime-6.0.0/src/xtr_mime/bundle/add_mime_type_guesser_pass.py +99 -0
- xtr_mime-6.0.0/src/xtr_mime/bundle/mime_bundle.py +119 -0
- xtr_mime-6.0.0/src/xtr_mime/bundle/mime_config.py +56 -0
- xtr_mime-6.0.0/src/xtr_mime/crypto/__init__.py +17 -0
- xtr_mime-6.0.0/src/xtr_mime/crypto/_dkimpy.py +112 -0
- xtr_mime-6.0.0/src/xtr_mime/crypto/_pkcs7.py +204 -0
- xtr_mime-6.0.0/src/xtr_mime/crypto/_s_mime.py +72 -0
- xtr_mime-6.0.0/src/xtr_mime/crypto/dkim_options.py +85 -0
- xtr_mime-6.0.0/src/xtr_mime/crypto/dkim_signer.py +242 -0
- xtr_mime-6.0.0/src/xtr_mime/crypto/s_mime_encrypter.py +134 -0
- xtr_mime-6.0.0/src/xtr_mime/crypto/s_mime_signer.py +122 -0
- xtr_mime-6.0.0/src/xtr_mime/crypto/signed_body.py +101 -0
- xtr_mime-6.0.0/src/xtr_mime/draft_email.py +46 -0
- xtr_mime-6.0.0/src/xtr_mime/email.py +315 -0
- xtr_mime-6.0.0/src/xtr_mime/exception/__init__.py +17 -0
- xtr_mime-6.0.0/src/xtr_mime/exception/invalid_argument_error.py +15 -0
- xtr_mime-6.0.0/src/xtr_mime/exception/logic_error.py +15 -0
- xtr_mime-6.0.0/src/xtr_mime/exception/mime_error.py +9 -0
- xtr_mime-6.0.0/src/xtr_mime/exception/mime_runtime_error.py +14 -0
- xtr_mime-6.0.0/src/xtr_mime/exception/rfc_compliance_error.py +16 -0
- xtr_mime-6.0.0/src/xtr_mime/group.py +48 -0
- xtr_mime-6.0.0/src/xtr_mime/header/__init__.py +34 -0
- xtr_mime-6.0.0/src/xtr_mime/header/_header_builder.py +84 -0
- xtr_mime-6.0.0/src/xtr_mime/header/abstract_header.py +141 -0
- xtr_mime-6.0.0/src/xtr_mime/header/date_header.py +60 -0
- xtr_mime-6.0.0/src/xtr_mime/header/folded_header.py +49 -0
- xtr_mime-6.0.0/src/xtr_mime/header/header_interface.py +66 -0
- xtr_mime-6.0.0/src/xtr_mime/header/headers.py +276 -0
- xtr_mime-6.0.0/src/xtr_mime/header/identification_header.py +108 -0
- xtr_mime-6.0.0/src/xtr_mime/header/mailbox_header.py +58 -0
- xtr_mime-6.0.0/src/xtr_mime/header/mailbox_list_header.py +115 -0
- xtr_mime-6.0.0/src/xtr_mime/header/parameterized_header.py +94 -0
- xtr_mime-6.0.0/src/xtr_mime/header/path_header.py +63 -0
- xtr_mime-6.0.0/src/xtr_mime/header/unstructured_header.py +61 -0
- xtr_mime-6.0.0/src/xtr_mime/html_to_text_converter/__init__.py +8 -0
- xtr_mime-6.0.0/src/xtr_mime/html_to_text_converter/default_html_to_text_converter.py +113 -0
- xtr_mime-6.0.0/src/xtr_mime/html_to_text_converter/html_to_text_converter_interface.py +25 -0
- xtr_mime-6.0.0/src/xtr_mime/magic_mime_type_guesser.py +92 -0
- xtr_mime-6.0.0/src/xtr_mime/message.py +179 -0
- xtr_mime-6.0.0/src/xtr_mime/message_converter.py +130 -0
- xtr_mime-6.0.0/src/xtr_mime/mime_type_guesser_interface.py +33 -0
- xtr_mime-6.0.0/src/xtr_mime/mime_types.py +137 -0
- xtr_mime-6.0.0/src/xtr_mime/mime_types_interface.py +36 -0
- xtr_mime-6.0.0/src/xtr_mime/part/__init__.py +32 -0
- xtr_mime-6.0.0/src/xtr_mime/part/abstract_multipart_part.py +77 -0
- xtr_mime-6.0.0/src/xtr_mime/part/abstract_part.py +112 -0
- xtr_mime-6.0.0/src/xtr_mime/part/data_part.py +135 -0
- xtr_mime-6.0.0/src/xtr_mime/part/file.py +59 -0
- xtr_mime-6.0.0/src/xtr_mime/part/message_part.py +82 -0
- xtr_mime-6.0.0/src/xtr_mime/part/multipart/__init__.py +11 -0
- xtr_mime-6.0.0/src/xtr_mime/part/multipart/alternative_part.py +23 -0
- xtr_mime-6.0.0/src/xtr_mime/part/multipart/digest_part.py +30 -0
- xtr_mime-6.0.0/src/xtr_mime/part/multipart/form_data_part.py +243 -0
- xtr_mime-6.0.0/src/xtr_mime/part/multipart/mixed_part.py +23 -0
- xtr_mime-6.0.0/src/xtr_mime/part/multipart/related_part.py +40 -0
- xtr_mime-6.0.0/src/xtr_mime/part/s_mime_part.py +86 -0
- xtr_mime-6.0.0/src/xtr_mime/part/text_part.py +220 -0
- xtr_mime-6.0.0/src/xtr_mime/py.typed +0 -0
- xtr_mime-6.0.0/src/xtr_mime/raw_message.py +134 -0
- xtr_mime-6.0.0/src/xtr_mime/recipe/__init__.py +16 -0
- xtr_mime-6.0.0/src/xtr_mime/recipe/manifest.toml +19 -0
- xtr_mime-6.0.0/src/xtr_mime/testing/__init__.py +51 -0
- xtr_mime-6.0.0/src/xtr_mime/testing/_body_assertions.py +157 -0
- xtr_mime-6.0.0/src/xtr_mime/testing/_failure.py +16 -0
- xtr_mime-6.0.0/src/xtr_mime/testing/_header_assertions.py +120 -0
xtr_mime-6.0.0/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xterr
|
|
4
|
+
Copyright (c) 2010-present Fabien Potencier
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
xtr_mime-6.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xtr-mime
|
|
3
|
+
Version: 6.0.0
|
|
4
|
+
Summary: Build email messages — addresses, headers, text and HTML bodies, attachments and inline images — rendered to standard MIME by Python's own email package, signed with DKIM or S/MIME when asked.
|
|
5
|
+
Keywords: email,mime,smtp,dkim,smime
|
|
6
|
+
Author: Xterr
|
|
7
|
+
Author-email: Xterr <me@xterr.dev>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Dist: typing-extensions>=4.4
|
|
18
|
+
Requires-Dist: xtr-clock>=6.0,<7
|
|
19
|
+
Requires-Dist: idna>=3.10
|
|
20
|
+
Requires-Dist: email-validator>=2.3
|
|
21
|
+
Requires-Dist: dkimpy>=1.1.8,<2 ; extra == 'dkim'
|
|
22
|
+
Requires-Dist: cryptography>=45 ; extra == 'dkim'
|
|
23
|
+
Requires-Dist: python-magic>=0.4.27 ; extra == 'magic'
|
|
24
|
+
Requires-Dist: cryptography>=45 ; extra == 'smime'
|
|
25
|
+
Requires-Python: >=3.11
|
|
26
|
+
Provides-Extra: dkim
|
|
27
|
+
Provides-Extra: magic
|
|
28
|
+
Provides-Extra: smime
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
<div align="center">
|
|
32
|
+
|
|
33
|
+
# xtr-mime
|
|
34
|
+
|
|
35
|
+
**Build email messages — addresses, headers, text and HTML bodies, attachments and inline images — rendered to standard MIME by Python's own email package, signed with DKIM or S/MIME when asked.**
|
|
36
|
+
|
|
37
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
38
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
39
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
40
|
+
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Why?
|
|
46
|
+
|
|
47
|
+
Python's `email` package writes correct MIME, but building a message with it means knowing which
|
|
48
|
+
part nests inside which, which header takes an address list and which takes a date, and when a
|
|
49
|
+
`Content-ID` has to match a `cid:` in the HTML. xtr-mime is the typed model on top: an `Email`
|
|
50
|
+
builder with addresses that are checked, headers that are typed values, attachments and inline
|
|
51
|
+
images that are put where they belong, and a message that is valid to send or refuses to
|
|
52
|
+
render. The `email` package still writes every byte — header encoding and folding included — so
|
|
53
|
+
what goes on the wire is what the standard library writes.
|
|
54
|
+
|
|
55
|
+
- **Typed all the way down.** `Address`, eleven header classes, parts and multiparts; nothing from
|
|
56
|
+
the `email` package appears in a signature.
|
|
57
|
+
- **Checked addresses.** The mail grammar, international addresses (RFC 6532) and domains,
|
|
58
|
+
without asking DNS anything.
|
|
59
|
+
- **Signing.** DKIM through dkimpy, S/MIME signing and encryption through cryptography — both
|
|
60
|
+
optional extras.
|
|
61
|
+
- **Testing helpers** that read an email the way a test thinks about it, with no test framework
|
|
62
|
+
imported.
|
|
63
|
+
|
|
64
|
+
## Install
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
uv add xtr-mime
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
| Extra | Brings | For |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `dkim` | dkimpy, cryptography | `DkimSigner` |
|
|
73
|
+
| `smime` | cryptography | `SMimeSigner`, `SMimeEncrypter` |
|
|
74
|
+
| `magic` | python-magic | `MagicMimeTypeGuesser`, which also needs libmagic itself: `brew install libmagic`, `apt install libmagic1` |
|
|
75
|
+
|
|
76
|
+
`uv add "xtr-mime[dkim,smime]"` takes several. Without `dkim` or `smime`, the signers still
|
|
77
|
+
import; building one raises `LogicError` naming the extra to install. Without `magic` or
|
|
78
|
+
libmagic, `MagicMimeTypeGuesser` declines to guess.
|
|
79
|
+
|
|
80
|
+
## Quick start
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from xtr_mime import Address, Email
|
|
84
|
+
|
|
85
|
+
email = (
|
|
86
|
+
Email()
|
|
87
|
+
.from_(Address("shop@example.com", "Example Shop"))
|
|
88
|
+
.to("ana@example.com")
|
|
89
|
+
.subject("Your order has shipped")
|
|
90
|
+
.text("Your order is on its way.")
|
|
91
|
+
.html("<p>Your order is <strong>on its way</strong>.</p>")
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
wire = email.to_string() # the whole message, CRLF line ends
|
|
95
|
+
assert "Subject: Your order has shipped\r\n" in wire
|
|
96
|
+
assert "Content-Type: multipart/alternative;" in wire
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`from_` carries an underscore because `from` is a keyword; its getter is `get_from()`. Every other
|
|
100
|
+
setter is the header's name: `to`, `cc`, `bcc`, `reply_to`, `sender`, `return_path`, `date`,
|
|
101
|
+
`priority`; `add_to` and the like append. `to_iterable()` yields the same message as bytes, in
|
|
102
|
+
pieces, for a socket. Rendering adds `Date`, `Message-ID` and `MIME-Version`, and refuses an email
|
|
103
|
+
with no `From` or `Sender`, or no body (`LogicError`); `ensure_validity()` also refuses one naming
|
|
104
|
+
no recipient, as a mailer checks before sending.
|
|
105
|
+
|
|
106
|
+
### Attachments and inline images
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
email = (
|
|
110
|
+
Email()
|
|
111
|
+
.from_("shop@example.com")
|
|
112
|
+
.to("ana@example.com")
|
|
113
|
+
.subject("Invoice A-17")
|
|
114
|
+
.text("Your invoice is attached.")
|
|
115
|
+
.html('<p><img src="cid:logo.png"> Your invoice is attached.</p>')
|
|
116
|
+
.attach_from_path("invoice.pdf") # read when the email is rendered
|
|
117
|
+
.attach(b"order,total\nA-17,19.99\n", "order.csv", "text/csv")
|
|
118
|
+
.embed_from_path("logo.png") # shown where the HTML says cid:logo.png
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
wire = email.to_string()
|
|
122
|
+
assert "Content-Type: multipart/mixed;" in wire
|
|
123
|
+
assert "Content-Type: multipart/related;" in wire
|
|
124
|
+
assert 'Content-Disposition: attachment; name="invoice.pdf"; filename="invoice.pdf"' in wire
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
A file is named by its extension (`invoice.pdf` is `application/pdf`) unless `content_type` says
|
|
128
|
+
otherwise, and read only when the email is rendered. An embedded part is given a `Content-ID`, and
|
|
129
|
+
the `cid:` naming it in the HTML is rewritten to match.
|
|
130
|
+
|
|
131
|
+
### Text from HTML
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from xtr_mime import DefaultHtmlToTextConverter
|
|
135
|
+
|
|
136
|
+
converter = DefaultHtmlToTextConverter()
|
|
137
|
+
html = "<h1>Hello</h1>\n<p>Your order is <b>on its way</b>.</p>\n<script>track()</script>"
|
|
138
|
+
|
|
139
|
+
assert converter.convert(html, "utf-8") == "Hello\nYour order is on its way."
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The text alternative an HTML-only email is sent with. Implement `HtmlToTextConverterInterface`
|
|
143
|
+
for another converter; see [Known limitations](#known-limitations) for what this one keeps.
|
|
144
|
+
|
|
145
|
+
### Sign with DKIM
|
|
146
|
+
|
|
147
|
+
Needs the `dkim` extra.
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from pathlib import Path
|
|
151
|
+
|
|
152
|
+
from xtr_mime import DkimOptions, DkimSigner
|
|
153
|
+
|
|
154
|
+
signer = DkimSigner(Path("dkim.pem"), "example.com", "mail")
|
|
155
|
+
signed = signer.sign(email, DkimOptions().header_canon(DkimSigner.CANON_SIMPLE))
|
|
156
|
+
|
|
157
|
+
assert signed.to_string().startswith("DKIM-Signature: v=1; a=rsa-sha256; c=simple/relaxed;")
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The key is PEM text, a path, or a string starting with `file://`, opened when the signer is built
|
|
161
|
+
(pass `passphrase=` for an encrypted one). The signature covers every header but `Return-Path`,
|
|
162
|
+
`X-Transport` and those `headers_to_ignore` names; sign the message last, after anything else that
|
|
163
|
+
changes it.
|
|
164
|
+
The signed message carries the field as a `FoldedHeader`, written byte for byte as it was folded —
|
|
165
|
+
as `simple` canonicalization needs — and its body as a `SignedBody`, the bytes signed;
|
|
166
|
+
`SignedBody.from_frozen` builds one again from what it holds, in another process.
|
|
167
|
+
|
|
168
|
+
### Sign and encrypt with S/MIME
|
|
169
|
+
|
|
170
|
+
Needs the `smime` extra.
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
from xtr_mime import SMimeEncrypter, SMimeSigner
|
|
174
|
+
|
|
175
|
+
signed = SMimeSigner("signer.crt", "signer.key").sign(email)
|
|
176
|
+
encrypted = SMimeEncrypter("recipient.crt").encrypt(signed)
|
|
177
|
+
|
|
178
|
+
assert 'Content-Type: multipart/signed; protocol="application/x-pkcs7-signature";' in (
|
|
179
|
+
signed.to_string()
|
|
180
|
+
)
|
|
181
|
+
assert 'Content-Type: application/pkcs7-mime; smime-type="enveloped-data";' in (
|
|
182
|
+
encrypted.to_string()
|
|
183
|
+
)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`SMimeEncrypter` takes one certificate path or several, one per recipient. The headers stay
|
|
187
|
+
outside the encryption, readable, so the message can be delivered.
|
|
188
|
+
|
|
189
|
+
### MIME types
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
from xtr_mime import MimeTypes
|
|
193
|
+
|
|
194
|
+
mime_types = MimeTypes({"application/mbox": ["mbox"]})
|
|
195
|
+
|
|
196
|
+
assert mime_types.get_extensions("image/jpeg")[0] == "jpg"
|
|
197
|
+
assert mime_types.get_mime_types("mbox") == ["application/mbox"]
|
|
198
|
+
assert mime_types.guess_mime_type("invoice.pdf") == "application/pdf"
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The table is the standard library's built-in one, plus the extra map. `guess_mime_type` asks the
|
|
202
|
+
registered guessers first — `MagicMimeTypeGuesser`, which reads the file's content where libmagic
|
|
203
|
+
is installed, and any `register_guesser` adds — then falls back to the file's extension.
|
|
204
|
+
|
|
205
|
+
## Testing
|
|
206
|
+
|
|
207
|
+
`xtr_mime.testing` checks an email the way a test thinks about it, raising `AssertionError` with
|
|
208
|
+
what was expected and what was found. It imports no test framework.
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
from xtr_mime import Email
|
|
212
|
+
from xtr_mime.testing import (
|
|
213
|
+
assert_email_address_contains,
|
|
214
|
+
assert_email_attachment_count,
|
|
215
|
+
assert_email_header_same,
|
|
216
|
+
assert_email_subject_contains,
|
|
217
|
+
assert_email_text_body_contains,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
email = (
|
|
221
|
+
Email()
|
|
222
|
+
.from_("shop@example.com")
|
|
223
|
+
.to("ana@example.com")
|
|
224
|
+
.subject("Your order has shipped")
|
|
225
|
+
.text("Hello Ana")
|
|
226
|
+
.attach(b"A-17", "order.txt", "text/plain")
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
assert_email_header_same(email, "To", "ana@example.com")
|
|
230
|
+
assert_email_address_contains(email, "To", "ana@example.com")
|
|
231
|
+
assert_email_subject_contains(email, "shipped")
|
|
232
|
+
assert_email_text_body_contains(email, "Ana")
|
|
233
|
+
assert_email_attachment_count(email, 1)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Most have a negated twin (`assert_email_header_not_same`, `assert_email_not_has_header`,
|
|
237
|
+
`assert_email_subject_not_contains`, `assert_email_text_body_not_contains`,
|
|
238
|
+
`assert_email_html_body_not_contains`). A check asked about something a message cannot have — the
|
|
239
|
+
subject of raw bytes — raises `LogicError`: the test is wrong, not the email. Pass `clock=` to
|
|
240
|
+
`Email` with xtr-clock's `MockClock` to freeze its `Date`.
|
|
241
|
+
|
|
242
|
+
## Use in an application
|
|
243
|
+
|
|
244
|
+
Everything adding this package to an application on
|
|
245
|
+
[xtr-dependency-injection](../xtr-dependency-injection) takes — and, read backwards, what
|
|
246
|
+
removing it undoes.
|
|
247
|
+
|
|
248
|
+
- **Install** — `uv add xtr-mime`; add `dkim`, `smime` or `magic` for what each brings. The
|
|
249
|
+
container the bundle plugs into, xtr-dependency-injection, is the application's own.
|
|
250
|
+
- **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate* step below: it lists
|
|
251
|
+
`MimeBundle`. There is no config file, environment or ignore line to write.
|
|
252
|
+
- **Activate** — `MimeBundle: {"all": True}` in `BUNDLES` in `<app>/config/bundles.py`, imported
|
|
253
|
+
from `xtr_mime.bundle`.
|
|
254
|
+
- **Brings along** — nothing: the bundle requires no other.
|
|
255
|
+
- **Configure** — optional: with no configuration the table is the standard library's built-in
|
|
256
|
+
one, asking every guesser **tagged** for it, and a class subclassing `MimeTypeGuesserInterface`
|
|
257
|
+
is tagged without being told. Extensions the table lacks go in a
|
|
258
|
+
`<app>/config/packages/mime.py` `@configure` function returning `MimeConfig` — see
|
|
259
|
+
[Kernel / bundle](#kernel--bundle). They apply to the container's `MimeTypes` service only:
|
|
260
|
+
a `File` attachment reads `MimeTypes.get_default()`, which the bundle never changes.
|
|
261
|
+
- **Environment** — nothing read from the environment.
|
|
262
|
+
- **Ignore** — nothing: the bundle writes no file into the project.
|
|
263
|
+
- **Remove** — drop the `BUNDLES` entry (`uv run xtr-recipes recipes:remove xtr-mime` does it, and
|
|
264
|
+
there is no file of its own to take away), delete `<app>/config/packages/mime.py` if you wrote
|
|
265
|
+
one, then `uv remove xtr-mime`.
|
|
266
|
+
- **Check** — `debug:bundles` shows `mime` as `listed` and `active`.
|
|
267
|
+
|
|
268
|
+
## Kernel / bundle
|
|
269
|
+
|
|
270
|
+
With xtr-dependency-injection in the application, list the bundle:
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
# app/config/bundles.py
|
|
274
|
+
from xtr_mime.bundle import MimeBundle
|
|
275
|
+
|
|
276
|
+
BUNDLES = {MimeBundle: {"all": True}}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
It registers:
|
|
280
|
+
|
|
281
|
+
| Service | Under | Built |
|
|
282
|
+
|---|---|---|
|
|
283
|
+
| `MimeTypes` | `MimeTypesInterface`, `MimeTypeGuesserInterface` | when first asked for; the container's own table, never `MimeTypes.get_default()` |
|
|
284
|
+
| `DefaultHtmlToTextConverter` | `HtmlToTextConverterInterface` | when first asked for |
|
|
285
|
+
|
|
286
|
+
Nothing is built at boot, and nothing is read from disk until the table is.
|
|
287
|
+
|
|
288
|
+
A `File` attachment names its type from `MimeTypes.get_default()`, the table the whole process
|
|
289
|
+
shares, not from the container's. To have attachments read the configured table, give each one
|
|
290
|
+
its `content_type`, or make the container's table the process's default yourself — a change to
|
|
291
|
+
every kernel and every caller in the process, which the bundle never makes for you:
|
|
292
|
+
|
|
293
|
+
```python
|
|
294
|
+
# app/boot/mime_default.py
|
|
295
|
+
from xtr_dependency_injection import Injected, on_boot
|
|
296
|
+
from xtr_mime import MimeTypes
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@on_boot
|
|
300
|
+
async def share_the_mime_types(mime_types: Injected[MimeTypes]) -> None:
|
|
301
|
+
MimeTypes.set_default(mime_types)
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
```python
|
|
305
|
+
# app/services/attachments.py
|
|
306
|
+
from xtr_dependency_injection import as_service
|
|
307
|
+
from xtr_mime import HtmlToTextConverterInterface, MimeTypesInterface
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@as_service
|
|
311
|
+
class Attachments:
|
|
312
|
+
def __init__(
|
|
313
|
+
self, mime_types: MimeTypesInterface, converter: HtmlToTextConverterInterface
|
|
314
|
+
) -> None:
|
|
315
|
+
self.mime_types = mime_types
|
|
316
|
+
self.converter = converter
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
| `MimeConfig` field | Default | What it does |
|
|
320
|
+
|---|---|---|
|
|
321
|
+
| `extra_map` | `{}` | Extensions by MIME type, each without its leading dot, ranked before the standard table's |
|
|
322
|
+
|
|
323
|
+
```python
|
|
324
|
+
# app/config/packages/mime.py
|
|
325
|
+
from xtr_dependency_injection import configure
|
|
326
|
+
from xtr_mime.bundle import MimeConfig
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@configure
|
|
330
|
+
def mime() -> MimeConfig:
|
|
331
|
+
return MimeConfig(extra_map={"application/mbox": ["mbox"]})
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
**Guessers.** A service tagged `MIME_TYPE_GUESSER_TAG` (`"mime.mime_type_guesser"`) is asked by
|
|
335
|
+
the table before it answers from a file's extension — the highest `priority` first, then in the
|
|
336
|
+
order they were registered; `MagicMimeTypeGuesser`, inside every table already, is asked after
|
|
337
|
+
them. A class subclassing `MimeTypeGuesserInterface` is tagged without being told; one that only
|
|
338
|
+
has its methods joins once tagged:
|
|
339
|
+
|
|
340
|
+
```python
|
|
341
|
+
# app/services/upload_guesser.py
|
|
342
|
+
from typing_extensions import override
|
|
343
|
+
from xtr_dependency_injection import as_service
|
|
344
|
+
from xtr_mime import MimeTypeGuesserInterface
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@as_service
|
|
348
|
+
class UploadGuesser(MimeTypeGuesserInterface):
|
|
349
|
+
@override
|
|
350
|
+
def is_guesser_supported(self) -> bool:
|
|
351
|
+
return True
|
|
352
|
+
|
|
353
|
+
@override
|
|
354
|
+
def guess_mime_type(self, path: str) -> str | None:
|
|
355
|
+
return "application/x-upload" if path.endswith(".upl") else None
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
A bundle tags one with a priority with
|
|
359
|
+
`services.set(UploadGuesser).add_tag(MIME_TYPE_GUESSER_TAG, priority=10)`. A tagged service that
|
|
360
|
+
is not a guesser fails the table when it is built, with `LogicError`; a priority that is not an
|
|
361
|
+
integer fails the build.
|
|
362
|
+
|
|
363
|
+
## Errors
|
|
364
|
+
|
|
365
|
+
Every error derives from `MimeError`, importable from `xtr_mime` and `xtr_mime.exception`.
|
|
366
|
+
|
|
367
|
+
| Error | Raised when |
|
|
368
|
+
|---|---|
|
|
369
|
+
| `MimeError` | The base: catch it to catch them all |
|
|
370
|
+
| `LogicError` | The library was used in a way that cannot work — an email with no sender or body, an extra not installed, a check on a message that cannot have what it asks about, a tagged service that is not a guesser. Also a `RuntimeError` |
|
|
371
|
+
| `MimeRuntimeError` | Something outside the code failed — an S/MIME certificate or key file missing or unreadable, libmagic failing on a file, the `ed25519-sha256` DKIM algorithm asked for. Also a `RuntimeError` |
|
|
372
|
+
| `InvalidArgumentError` | A value cannot be used — a missing or unreadable attachment, DKIM key file or file to guess the type of, an unknown charset or DKIM algorithm, a key that does not open, an S/MIME cipher cryptography cannot use, an extra map the table cannot read. Also a `ValueError` |
|
|
373
|
+
| `RfcComplianceError` | A value breaks the mail standards — an address that is not one, a domain with no ASCII form, a header line over 998 octets. An `InvalidArgumentError` |
|
|
374
|
+
|
|
375
|
+
## Known limitations
|
|
376
|
+
|
|
377
|
+
- **An email is rendered whole before its first chunk.** `to_iterable()` yields the message in
|
|
378
|
+
pieces, but only once all of it is in memory: Python's email package writes a message from
|
|
379
|
+
its whole tree, and an attachment's file is read whole into that tree. An email takes about
|
|
380
|
+
its rendered size in memory while it is sent, however large its attachments.
|
|
381
|
+
- **Header bytes are the email package's.** It picks quoted-printable or base64 for an encoded
|
|
382
|
+
word, writes it in lower case (`=?utf-8?q?...?=`) and folds where it chooses. Compare a header
|
|
383
|
+
decoded, never as bytes.
|
|
384
|
+
- **A long ASCII word in a text header is written as it is.** A word too long for one line —
|
|
385
|
+
a `List-Unsubscribe` URL — goes on a line of its own, unencoded, so it stays readable; a line
|
|
386
|
+
longer than 998 octets is refused with `RfcComplianceError`.
|
|
387
|
+
- **Folding a long non-ASCII header takes time with its length.** About a second for a
|
|
388
|
+
20,000-character subject on a laptop. Cap the length of what users type into a header.
|
|
389
|
+
- **A header parameter name is at most 40 characters.** A longer one is refused with
|
|
390
|
+
`RfcComplianceError`: when its value has to be cut into RFC 2231 sections, the name may leave
|
|
391
|
+
no room for them on a line.
|
|
392
|
+
- **A file's type comes from the built-in table.** `MimeTypes` reads the standard library's
|
|
393
|
+
built-in table, never the host's `mime.types` files, so a message is the same on every
|
|
394
|
+
machine. Content sniffing needs libmagic, which no wheel ships; without it
|
|
395
|
+
`MagicMimeTypeGuesser` declines and the extension decides.
|
|
396
|
+
- **A form field's name is escaped as browsers do.** `FormDataPart` writes a name or filename as
|
|
397
|
+
raw UTF-8 with `"`, CR and LF escaped, never in RFC 2231 form.
|
|
398
|
+
- **DKIM signs with `rsa-sha256` only.** `ed25519-sha256` is refused with `MimeRuntimeError`.
|
|
399
|
+
There is no expiry (`x=`) and no cap on the body length signed; `body_show_length` only writes
|
|
400
|
+
`l=` with the whole body's length. The signature's `t=` is read from the system clock by
|
|
401
|
+
dkimpy, so a `MockClock` does not freeze it.
|
|
402
|
+
- **A DKIM-signed message turns back into an email from the parts it was signed from.**
|
|
403
|
+
`MessageConverter` reads the text, HTML and attachments of the parts the signature was made
|
|
404
|
+
over, not the bytes signed. A body read from a stream that cannot seek back was read when
|
|
405
|
+
signing and comes out empty; attach bytes, a `File` or a seekable stream to a message you sign
|
|
406
|
+
and hand to a provider's API. A body signed or encrypted with S/MIME is refused.
|
|
407
|
+
- **A DKIM or S/MIME passphrase is for an encrypted key only.** `DkimSigner` and `SMimeSigner`
|
|
408
|
+
refuse a passphrase given with a key that is not encrypted (`InvalidArgumentError`), as
|
|
409
|
+
cryptography, which opens the key, refuses it; pass an empty passphrase for such a key.
|
|
410
|
+
- **S/MIME signatures are detached, and encryption is AES-CBC for RSA keys.** `SMimeSigner`
|
|
411
|
+
writes `multipart/signed` only — cryptography writes no opaque S/MIME signature — with an
|
|
412
|
+
RSA key of at least 1024 bits or an EC key, and a SHA-2 digest; a certificate for RSA-PSS
|
|
413
|
+
is refused, as cryptography signs with PKCS #1 v1.5 only. `SMimeEncrypter` encrypts with
|
|
414
|
+
`aes-128-cbc` or `aes-256-cbc` (the default) for certificates holding an RSA key; the RC2,
|
|
415
|
+
DES, 3DES and `aes-192-cbc` ciphers are refused with `InvalidArgumentError`.
|
|
416
|
+
- **The text made from an HTML body is written for reading, not the markup minus its tags.**
|
|
417
|
+
On purpose, `DefaultHtmlToTextConverter` decodes character references, drops `<script>` —
|
|
418
|
+
as it drops the head and `<style>` — with what it holds, collapses whitespace, and trims every
|
|
419
|
+
line and the whole text, so a `<pre>` block loses its indentation. Lines break where the
|
|
420
|
+
markup's own lines do, not at its elements: `<p>One</p><p>Two</p>` on one line reads `OneTwo`.
|
|
421
|
+
Where the text must keep the body's characters as written, use a converter of your own
|
|
422
|
+
implementing `HtmlToTextConverterInterface`.
|
|
423
|
+
- **Untrusted HTML needs an interpreter with the 2025 `html.parser` fixes.** The converter reads
|
|
424
|
+
HTML with the standard library's parser, which before CPython 3.11.14, 3.12.12 and 3.13.6
|
|
425
|
+
takes time growing with the square of the input on markup left unclosed, and loses the text
|
|
426
|
+
after a `<script>` or `<style>` that is never closed. Convert HTML you do not write on one of
|
|
427
|
+
those releases or a later one; every 3.14 release has the fixes.
|
|
428
|
+
- **The configured extra map is the container's alone.** `MimeConfig.extra_map` shapes the
|
|
429
|
+
container's `MimeTypes` service; a `File` attachment reads `MimeTypes.get_default()`, which
|
|
430
|
+
the bundle leaves as it is. Pass a `content_type`, or set the default yourself as
|
|
431
|
+
[Kernel / bundle](#kernel--bundle) shows — process-wide, last kernel to set it wins.
|
|
432
|
+
|
|
433
|
+
## Development
|
|
434
|
+
|
|
435
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
436
|
+
`packages/xtr-mime`; run the commands below from there. The `python-xtr-mime` repository is a
|
|
437
|
+
read-only copy, so send issues and pull requests to the monorepo.
|
|
438
|
+
|
|
439
|
+
```sh
|
|
440
|
+
uv sync
|
|
441
|
+
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
## License
|
|
445
|
+
|
|
446
|
+
MIT — see [LICENSE](LICENSE).
|