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.
Files changed (95) hide show
  1. xtr_mime-6.0.0/LICENSE +22 -0
  2. xtr_mime-6.0.0/PKG-INFO +446 -0
  3. xtr_mime-6.0.0/README.md +416 -0
  4. xtr_mime-6.0.0/pyproject.toml +184 -0
  5. xtr_mime-6.0.0/pyproject.toml.orig +192 -0
  6. xtr_mime-6.0.0/src/xtr_mime/.agents/skills/xtr-mime/SKILL.md +149 -0
  7. xtr_mime-6.0.0/src/xtr_mime/.agents/skills/xtr-mime/references/application.md +105 -0
  8. xtr_mime-6.0.0/src/xtr_mime/.agents/skills/xtr-mime/references/limitations.md +23 -0
  9. xtr_mime-6.0.0/src/xtr_mime/__init__.py +122 -0
  10. xtr_mime-6.0.0/src/xtr_mime/_display_name.py +38 -0
  11. xtr_mime-6.0.0/src/xtr_mime/_email_body.py +126 -0
  12. xtr_mime-6.0.0/src/xtr_mime/_email_headers.py +82 -0
  13. xtr_mime-6.0.0/src/xtr_mime/_engine/__init__.py +115 -0
  14. xtr_mime-6.0.0/src/xtr_mime/_engine/_address_list.py +136 -0
  15. xtr_mime-6.0.0/src/xtr_mime/_engine/_addresses.py +103 -0
  16. xtr_mime-6.0.0/src/xtr_mime/_engine/_build.py +216 -0
  17. xtr_mime-6.0.0/src/xtr_mime/_engine/_fold.py +166 -0
  18. xtr_mime-6.0.0/src/xtr_mime/_engine/_grammar.py +166 -0
  19. xtr_mime-6.0.0/src/xtr_mime/_engine/_parameters.py +104 -0
  20. xtr_mime-6.0.0/src/xtr_mime/_engine/_policy.py +214 -0
  21. xtr_mime-6.0.0/src/xtr_mime/_engine/_render.py +141 -0
  22. xtr_mime-6.0.0/src/xtr_mime/_engine/_unstructured.py +84 -0
  23. xtr_mime-6.0.0/src/xtr_mime/_engine/_values.py +108 -0
  24. xtr_mime-6.0.0/src/xtr_mime/_error_text.py +66 -0
  25. xtr_mime-6.0.0/src/xtr_mime/_header_reader.py +230 -0
  26. xtr_mime-6.0.0/src/xtr_mime/_html_references.py +79 -0
  27. xtr_mime-6.0.0/src/xtr_mime/_readable_file.py +24 -0
  28. xtr_mime-6.0.0/src/xtr_mime/address.py +265 -0
  29. xtr_mime-6.0.0/src/xtr_mime/body_renderer_interface.py +23 -0
  30. xtr_mime-6.0.0/src/xtr_mime/bundle/__init__.py +14 -0
  31. xtr_mime-6.0.0/src/xtr_mime/bundle/add_mime_type_guesser_pass.py +99 -0
  32. xtr_mime-6.0.0/src/xtr_mime/bundle/mime_bundle.py +119 -0
  33. xtr_mime-6.0.0/src/xtr_mime/bundle/mime_config.py +56 -0
  34. xtr_mime-6.0.0/src/xtr_mime/crypto/__init__.py +17 -0
  35. xtr_mime-6.0.0/src/xtr_mime/crypto/_dkimpy.py +112 -0
  36. xtr_mime-6.0.0/src/xtr_mime/crypto/_pkcs7.py +204 -0
  37. xtr_mime-6.0.0/src/xtr_mime/crypto/_s_mime.py +72 -0
  38. xtr_mime-6.0.0/src/xtr_mime/crypto/dkim_options.py +85 -0
  39. xtr_mime-6.0.0/src/xtr_mime/crypto/dkim_signer.py +242 -0
  40. xtr_mime-6.0.0/src/xtr_mime/crypto/s_mime_encrypter.py +134 -0
  41. xtr_mime-6.0.0/src/xtr_mime/crypto/s_mime_signer.py +122 -0
  42. xtr_mime-6.0.0/src/xtr_mime/crypto/signed_body.py +101 -0
  43. xtr_mime-6.0.0/src/xtr_mime/draft_email.py +46 -0
  44. xtr_mime-6.0.0/src/xtr_mime/email.py +315 -0
  45. xtr_mime-6.0.0/src/xtr_mime/exception/__init__.py +17 -0
  46. xtr_mime-6.0.0/src/xtr_mime/exception/invalid_argument_error.py +15 -0
  47. xtr_mime-6.0.0/src/xtr_mime/exception/logic_error.py +15 -0
  48. xtr_mime-6.0.0/src/xtr_mime/exception/mime_error.py +9 -0
  49. xtr_mime-6.0.0/src/xtr_mime/exception/mime_runtime_error.py +14 -0
  50. xtr_mime-6.0.0/src/xtr_mime/exception/rfc_compliance_error.py +16 -0
  51. xtr_mime-6.0.0/src/xtr_mime/group.py +48 -0
  52. xtr_mime-6.0.0/src/xtr_mime/header/__init__.py +34 -0
  53. xtr_mime-6.0.0/src/xtr_mime/header/_header_builder.py +84 -0
  54. xtr_mime-6.0.0/src/xtr_mime/header/abstract_header.py +141 -0
  55. xtr_mime-6.0.0/src/xtr_mime/header/date_header.py +60 -0
  56. xtr_mime-6.0.0/src/xtr_mime/header/folded_header.py +49 -0
  57. xtr_mime-6.0.0/src/xtr_mime/header/header_interface.py +66 -0
  58. xtr_mime-6.0.0/src/xtr_mime/header/headers.py +276 -0
  59. xtr_mime-6.0.0/src/xtr_mime/header/identification_header.py +108 -0
  60. xtr_mime-6.0.0/src/xtr_mime/header/mailbox_header.py +58 -0
  61. xtr_mime-6.0.0/src/xtr_mime/header/mailbox_list_header.py +115 -0
  62. xtr_mime-6.0.0/src/xtr_mime/header/parameterized_header.py +94 -0
  63. xtr_mime-6.0.0/src/xtr_mime/header/path_header.py +63 -0
  64. xtr_mime-6.0.0/src/xtr_mime/header/unstructured_header.py +61 -0
  65. xtr_mime-6.0.0/src/xtr_mime/html_to_text_converter/__init__.py +8 -0
  66. xtr_mime-6.0.0/src/xtr_mime/html_to_text_converter/default_html_to_text_converter.py +113 -0
  67. xtr_mime-6.0.0/src/xtr_mime/html_to_text_converter/html_to_text_converter_interface.py +25 -0
  68. xtr_mime-6.0.0/src/xtr_mime/magic_mime_type_guesser.py +92 -0
  69. xtr_mime-6.0.0/src/xtr_mime/message.py +179 -0
  70. xtr_mime-6.0.0/src/xtr_mime/message_converter.py +130 -0
  71. xtr_mime-6.0.0/src/xtr_mime/mime_type_guesser_interface.py +33 -0
  72. xtr_mime-6.0.0/src/xtr_mime/mime_types.py +137 -0
  73. xtr_mime-6.0.0/src/xtr_mime/mime_types_interface.py +36 -0
  74. xtr_mime-6.0.0/src/xtr_mime/part/__init__.py +32 -0
  75. xtr_mime-6.0.0/src/xtr_mime/part/abstract_multipart_part.py +77 -0
  76. xtr_mime-6.0.0/src/xtr_mime/part/abstract_part.py +112 -0
  77. xtr_mime-6.0.0/src/xtr_mime/part/data_part.py +135 -0
  78. xtr_mime-6.0.0/src/xtr_mime/part/file.py +59 -0
  79. xtr_mime-6.0.0/src/xtr_mime/part/message_part.py +82 -0
  80. xtr_mime-6.0.0/src/xtr_mime/part/multipart/__init__.py +11 -0
  81. xtr_mime-6.0.0/src/xtr_mime/part/multipart/alternative_part.py +23 -0
  82. xtr_mime-6.0.0/src/xtr_mime/part/multipart/digest_part.py +30 -0
  83. xtr_mime-6.0.0/src/xtr_mime/part/multipart/form_data_part.py +243 -0
  84. xtr_mime-6.0.0/src/xtr_mime/part/multipart/mixed_part.py +23 -0
  85. xtr_mime-6.0.0/src/xtr_mime/part/multipart/related_part.py +40 -0
  86. xtr_mime-6.0.0/src/xtr_mime/part/s_mime_part.py +86 -0
  87. xtr_mime-6.0.0/src/xtr_mime/part/text_part.py +220 -0
  88. xtr_mime-6.0.0/src/xtr_mime/py.typed +0 -0
  89. xtr_mime-6.0.0/src/xtr_mime/raw_message.py +134 -0
  90. xtr_mime-6.0.0/src/xtr_mime/recipe/__init__.py +16 -0
  91. xtr_mime-6.0.0/src/xtr_mime/recipe/manifest.toml +19 -0
  92. xtr_mime-6.0.0/src/xtr_mime/testing/__init__.py +51 -0
  93. xtr_mime-6.0.0/src/xtr_mime/testing/_body_assertions.py +157 -0
  94. xtr_mime-6.0.0/src/xtr_mime/testing/_failure.py +16 -0
  95. 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.
@@ -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&nbsp;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).