epistole 0.0.2__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.
- epistole-0.0.2/LICENSE +21 -0
- epistole-0.0.2/PKG-INFO +249 -0
- epistole-0.0.2/README.md +218 -0
- epistole-0.0.2/pyproject.toml +58 -0
- epistole-0.0.2/pyproject.toml.orig +44 -0
- epistole-0.0.2/src/epistole/__init__.py +0 -0
- epistole-0.0.2/src/epistole/py.typed +0 -0
epistole-0.0.2/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ozan Ozbeker
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
epistole-0.0.2/PKG-INFO
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: epistole
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: A single Python API for sending email, regardless of the backend behind it.
|
|
5
|
+
Keywords: email,gmail,microsoft-graph,smtp
|
|
6
|
+
Author: Ozan Ozbeker
|
|
7
|
+
Author-email: Ozan Ozbeker <github@ozanozbeker.com>
|
|
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.13
|
|
13
|
+
Classifier: Topic :: Communications :: Email
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Dist: epistole[gmail,graph,markdown] ; extra == 'all'
|
|
16
|
+
Requires-Dist: google-auth>=2.57 ; extra == 'gmail'
|
|
17
|
+
Requires-Dist: httpx2>=2.12,<3 ; extra == 'gmail'
|
|
18
|
+
Requires-Dist: httpx2>=2.12,<3 ; extra == 'graph'
|
|
19
|
+
Requires-Dist: msal>=1.38 ; extra == 'graph'
|
|
20
|
+
Requires-Dist: markdown-it-py>=4.2 ; extra == 'markdown'
|
|
21
|
+
Requires-Python: >=3.13
|
|
22
|
+
Project-URL: Changelog, https://github.com/ozanozbeker/epistole/blob/main/CHANGELOG.md
|
|
23
|
+
Project-URL: Homepage, https://github.com/ozanozbeker/epistole
|
|
24
|
+
Project-URL: Issues, https://github.com/ozanozbeker/epistole/issues
|
|
25
|
+
Project-URL: Repository, https://github.com/ozanozbeker/epistole
|
|
26
|
+
Provides-Extra: all
|
|
27
|
+
Provides-Extra: gmail
|
|
28
|
+
Provides-Extra: graph
|
|
29
|
+
Provides-Extra: markdown
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# Epistole
|
|
33
|
+
|
|
34
|
+
A single Python API for sending email, regardless of the backend.
|
|
35
|
+
|
|
36
|
+
`epistole` takes an email you have already composed as HTML and sends it through whichever service you have configured, using the same interface each time.
|
|
37
|
+
Supported backends are SMTP, Microsoft Graph, and Google, with room for more.
|
|
38
|
+
Switching providers means changing configuration, not rewriting your code.
|
|
39
|
+
|
|
40
|
+
This is an early project and the API is not yet stable.
|
|
41
|
+
|
|
42
|
+
## User guide
|
|
43
|
+
|
|
44
|
+
Work in progress.
|
|
45
|
+
Written while the API is still being designed, so the names are provisional and none of this runs yet.
|
|
46
|
+
The shapes are settled: a `Message` is an immutable value you build by chaining, a backend holds the credentials and the from address, and `backend.send(message)` or `connection.send(message)` sends it.
|
|
47
|
+
|
|
48
|
+
### One report, one recipient
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
from epistole import Message, SMTPBackend
|
|
53
|
+
|
|
54
|
+
smtp = SMTPBackend(host="mail.corp.example", port=587, from_address="reports@corp.example", ...)
|
|
55
|
+
|
|
56
|
+
smtp.send(Message(html=Path("kpis.html").read_text(encoding="utf-8")).subject("Daily KPIs").to("boss@corp.example"))
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The backend opens a connection, authenticates, submits, and closes, all inside that one call.
|
|
60
|
+
A wrong password raises `AuthenticationError` on that line.
|
|
61
|
+
Swap `SMTPBackend` for `GraphBackend` or `GmailBackend` and nothing else changes.
|
|
62
|
+
|
|
63
|
+
### One report, many recipients
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
report = (
|
|
67
|
+
Message(html=Path("weekly.html").read_text(encoding="utf-8"))
|
|
68
|
+
.subject("Weekly numbers")
|
|
69
|
+
.attach(Path("weekly.pdf"))
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
with smtp.connect() as connection:
|
|
73
|
+
for subscriber in subscribers:
|
|
74
|
+
connection.send(report.to(subscriber.email))
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
One authentication, then one send per subscriber over the same connection.
|
|
78
|
+
`.to()` replaces the recipient list on a copy, so each subscriber sees only their own address and the PDF is encoded once.
|
|
79
|
+
|
|
80
|
+
If subscriber 140 has a dead mailbox, that send raises `RecipientsRefusedError` and the connection stays open, so wrap the send in `try` and log it.
|
|
81
|
+
If the mail server restarts at subscriber 200, that send raises `TransportError`, the loop ends, and the connection closes quietly on the way out.
|
|
82
|
+
Nothing is skipped silently.
|
|
83
|
+
|
|
84
|
+
### Forgot `connect()`
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
for subscriber in subscribers:
|
|
88
|
+
smtp.send(report.to(subscriber.email))
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Still correct, only slower: one handshake per subscriber.
|
|
92
|
+
A relay with a rate limit may push back partway through, which surfaces as `ProviderError`.
|
|
93
|
+
Use `connect()` for loops.
|
|
94
|
+
|
|
95
|
+
### Kept a connection past its `with`
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
with smtp.connect() as connection:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
connection.send(report)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Raises `ValueError`, because the connection is closed.
|
|
105
|
+
This is a mistake in the calling code, not a mail failure, so it is not a `EpistoleError` and `except EpistoleError` does not swallow it.
|
|
106
|
+
A connection is one link, used once; to send again, call `connect()` again.
|
|
107
|
+
|
|
108
|
+
### Notebook, two cells, Graph
|
|
109
|
+
|
|
110
|
+
Cell one:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
connection = graph.connect()
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The token is acquired here, so a bad tenant id fails here rather than on the first send.
|
|
117
|
+
|
|
118
|
+
Cell two, an hour later:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
connection.send(message)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The connection refreshes its token through the credential you gave the backend, so an expired token is not an error.
|
|
125
|
+
Only a refresh that itself fails raises `AuthenticationError`, and the connection stays open either way.
|
|
126
|
+
Leaving the connection unclosed at the end of a notebook leaks nothing on Graph or Gmail.
|
|
127
|
+
On SMTP it leaves a socket open until the server times it out, which is why the loop above uses `with`.
|
|
128
|
+
|
|
129
|
+
### Threads
|
|
130
|
+
|
|
131
|
+
A backend is immutable and safe to share.
|
|
132
|
+
A connection is not: use one per thread, the same rule as a DB-API connection.
|
|
133
|
+
Epistole does not lock a connection for you; two threads on one connection is a bug in the caller.
|
|
134
|
+
|
|
135
|
+
### Markdown instead of HTML
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
note = (
|
|
139
|
+
Message(markdown="## Numbers\n\nSee the [dashboard](https://kpi.example).")
|
|
140
|
+
.subject("Numbers")
|
|
141
|
+
.to("boss@corp.example")
|
|
142
|
+
)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Needs the extra: `pip install "epistole[markdown]"`.
|
|
146
|
+
Without it, this line raises `ImportError` naming the extra.
|
|
147
|
+
The Markdown renders to HTML for clients that show it, and the source you wrote is the plain text for clients that do not.
|
|
148
|
+
Exactly one of `html=` or `markdown=` per message.
|
|
149
|
+
|
|
150
|
+
### Plain text only
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
smtp.send(
|
|
154
|
+
Message(text="Pipeline failed at 03:12. See run 4821.")
|
|
155
|
+
.subject("Pipeline failed")
|
|
156
|
+
.to("oncall@corp.example")
|
|
157
|
+
)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
No HTML part is made; the message is `text/plain` and every client renders it.
|
|
161
|
+
|
|
162
|
+
### A better plain-text part
|
|
163
|
+
|
|
164
|
+
Every HTML message carries plain text.
|
|
165
|
+
Epistole derives it with a small extractor of its own, which keeps links, marks list items, and drops the stylesheet.
|
|
166
|
+
To supply your own, pass `text=` and nothing is derived:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
Message(html=body, text=Path("weekly.txt").read_text(encoding="utf-8"))
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
To derive it with a library you prefer, pass `text_renderer=`, a callable from HTML to text.
|
|
173
|
+
It runs once, after Epistole has moved `data:` images out of the HTML, so no base64 lands in the text.
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
from inscriptis import get_text
|
|
177
|
+
from inscriptis.model.config import ParserConfig
|
|
178
|
+
|
|
179
|
+
config = ParserConfig(display_links=True)
|
|
180
|
+
|
|
181
|
+
Message(html=body, text_renderer=lambda h: get_text(h, config))
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`inscriptis` aligns table columns, which Epistole's extractor does not.
|
|
185
|
+
`html2text` works the same way through `HTML2Text().handle`; set `unicode_snob = True` on it or `Café` arrives as `Cafe`, and note its licence is GPL-3.0-or-later.
|
|
186
|
+
The default is exported as `epistole.html_to_text` if you want to wrap it.
|
|
187
|
+
|
|
188
|
+
## Choosing a backend
|
|
189
|
+
|
|
190
|
+
Epistole sends the same message through any backend, so choosing one is a deployment decision, not a code decision.
|
|
191
|
+
|
|
192
|
+
Use **SMTP** unless something stops you.
|
|
193
|
+
It works with every mail system, it carries the largest messages, and it sends exactly the MIME Epistole built.
|
|
194
|
+
|
|
195
|
+
Use **Graph** when your tenant has turned SMTP AUTH off, or when you need a retry hint on throttling.
|
|
196
|
+
Accept its 4 MB body limit before you choose it.
|
|
197
|
+
|
|
198
|
+
Use **Gmail** when you are already authenticated against a Google account and would rather not manage an SMTP credential.
|
|
199
|
+
|
|
200
|
+
| | SMTP | Gmail | Graph |
|
|
201
|
+
| --- | --- | --- | --- |
|
|
202
|
+
| Mail systems served | any | Google accounts | Exchange Online |
|
|
203
|
+
| Largest body | whole-message limit | whole-message limit | **4 MB, no path past it** |
|
|
204
|
+
| Largest message | server `SIZE`, 35 MB on a default Exchange Online tenant | 25 MB before encoding | 35 MB default, 1 MB to 150 MB configurable |
|
|
205
|
+
| Largest attachment | shares the message limit | shares the message limit | 150 MB, via upload session |
|
|
206
|
+
| Per-recipient refusals | visible | not expressible | not expressible |
|
|
207
|
+
| Retry hint | none | none documented | `Retry-After` |
|
|
208
|
+
| MIME you send | is what arrives | is what arrives | rebuilt by Exchange |
|
|
209
|
+
| Recipients per message | server policy | 500 | 500 |
|
|
210
|
+
| Credentials | anonymous, password, or OAuth | Google credentials | token credential |
|
|
211
|
+
|
|
212
|
+
Four things decide it.
|
|
213
|
+
|
|
214
|
+
**Body size.**
|
|
215
|
+
Graph caps the entire write request at 4 MB and has no chunked path for a message body, so a large embedded HTML report cannot be sent through it at all.
|
|
216
|
+
SMTP and Gmail measure against the whole message, so a body of several MB is routine on both.
|
|
217
|
+
If you send through Graph, attach the report as a file and keep the body small.
|
|
218
|
+
|
|
219
|
+
**Whether your tenant allows SMTP AUTH.**
|
|
220
|
+
On Exchange Online, security defaults and any policy that blocks basic authentication switch SMTP client submission off, and the trend runs one way.
|
|
221
|
+
Graph keeps working through all of it.
|
|
222
|
+
The per-mailbox SMTP AUTH setting overrides the organization setting, so one enabled mailbox is the documented workaround.
|
|
223
|
+
|
|
224
|
+
**Fidelity against features.**
|
|
225
|
+
SMTP and Gmail take the complete RFC 5322 message Epistole builds, so what you send is what arrives.
|
|
226
|
+
Graph takes a flat JSON array and Exchange serializes the MIME later, so Epistole can guarantee your `cid:` references resolve but not the MIME structure around them.
|
|
227
|
+
In exchange, Graph is the only backend that tells you how long to wait when it throttles you.
|
|
228
|
+
|
|
229
|
+
**What the permission costs.**
|
|
230
|
+
For a plain send the three are comparable: `SMTP.SendAsApp`, the `gmail.send` scope, `Mail.Send`.
|
|
231
|
+
Above 3 MB of attachment Graph needs a draft, which needs `Mail.ReadWrite`, which grants reading every message in scope.
|
|
232
|
+
Keep attachments under 3 MB if you send through Graph and a security reviewer will thank you.
|
|
233
|
+
|
|
234
|
+
`ConsoleBackend` and `MemoryBackend` are backends like any other; swapping one in is the same one-word change.
|
|
235
|
+
|
|
236
|
+
Limits quoted on 2026-09-08 and they move; re-check before relying on one.
|
|
237
|
+
Sources: [Graph request limits](https://learn.microsoft.com/en-us/graph/use-the-api), [Graph large attachments](https://learn.microsoft.com/en-us/graph/outlook-large-attachments), [Exchange Online limits](https://learn.microsoft.com/en-us/office365/servicedescriptions/exchange-online-service-description/exchange-online-limits), [Gmail sending limits](https://knowledge.workspace.google.com/admin/gmail/gmail-sending-limits-in-google-workspace), [Exchange rebuilds MIME](https://learn.microsoft.com/en-us/graph/outlook-things-to-know-about-send-mail), [SMTP `SIZE`, RFC 1870](https://datatracker.ietf.org/doc/html/rfc1870), [SMTP AUTH on Exchange Online](https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission).
|
|
238
|
+
Fuller working is in `docs/research/send-boundary-semantics.md` and `docs/research/attachment-and-inline-rules.md`.
|
|
239
|
+
|
|
240
|
+
## About the name
|
|
241
|
+
|
|
242
|
+
An epistole was a stone marker set at crossroads and roadsides in ancient Greece.
|
|
243
|
+
Travelers used them to tell which road led where.
|
|
244
|
+
The name fits a library whose job is to take one message and direct it down whichever road you have chosen.
|
|
245
|
+
|
|
246
|
+
## Credit
|
|
247
|
+
|
|
248
|
+
`epistole` is inspired by [blastula](https://github.com/rstudio/blastula), an R package for composing and sending email.
|
|
249
|
+
It is not a port, and the API does not mirror blastula's.
|
epistole-0.0.2/README.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# Epistole
|
|
2
|
+
|
|
3
|
+
A single Python API for sending email, regardless of the backend.
|
|
4
|
+
|
|
5
|
+
`epistole` takes an email you have already composed as HTML and sends it through whichever service you have configured, using the same interface each time.
|
|
6
|
+
Supported backends are SMTP, Microsoft Graph, and Google, with room for more.
|
|
7
|
+
Switching providers means changing configuration, not rewriting your code.
|
|
8
|
+
|
|
9
|
+
This is an early project and the API is not yet stable.
|
|
10
|
+
|
|
11
|
+
## User guide
|
|
12
|
+
|
|
13
|
+
Work in progress.
|
|
14
|
+
Written while the API is still being designed, so the names are provisional and none of this runs yet.
|
|
15
|
+
The shapes are settled: a `Message` is an immutable value you build by chaining, a backend holds the credentials and the from address, and `backend.send(message)` or `connection.send(message)` sends it.
|
|
16
|
+
|
|
17
|
+
### One report, one recipient
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from epistole import Message, SMTPBackend
|
|
22
|
+
|
|
23
|
+
smtp = SMTPBackend(host="mail.corp.example", port=587, from_address="reports@corp.example", ...)
|
|
24
|
+
|
|
25
|
+
smtp.send(Message(html=Path("kpis.html").read_text(encoding="utf-8")).subject("Daily KPIs").to("boss@corp.example"))
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The backend opens a connection, authenticates, submits, and closes, all inside that one call.
|
|
29
|
+
A wrong password raises `AuthenticationError` on that line.
|
|
30
|
+
Swap `SMTPBackend` for `GraphBackend` or `GmailBackend` and nothing else changes.
|
|
31
|
+
|
|
32
|
+
### One report, many recipients
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
report = (
|
|
36
|
+
Message(html=Path("weekly.html").read_text(encoding="utf-8"))
|
|
37
|
+
.subject("Weekly numbers")
|
|
38
|
+
.attach(Path("weekly.pdf"))
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
with smtp.connect() as connection:
|
|
42
|
+
for subscriber in subscribers:
|
|
43
|
+
connection.send(report.to(subscriber.email))
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
One authentication, then one send per subscriber over the same connection.
|
|
47
|
+
`.to()` replaces the recipient list on a copy, so each subscriber sees only their own address and the PDF is encoded once.
|
|
48
|
+
|
|
49
|
+
If subscriber 140 has a dead mailbox, that send raises `RecipientsRefusedError` and the connection stays open, so wrap the send in `try` and log it.
|
|
50
|
+
If the mail server restarts at subscriber 200, that send raises `TransportError`, the loop ends, and the connection closes quietly on the way out.
|
|
51
|
+
Nothing is skipped silently.
|
|
52
|
+
|
|
53
|
+
### Forgot `connect()`
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
for subscriber in subscribers:
|
|
57
|
+
smtp.send(report.to(subscriber.email))
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Still correct, only slower: one handshake per subscriber.
|
|
61
|
+
A relay with a rate limit may push back partway through, which surfaces as `ProviderError`.
|
|
62
|
+
Use `connect()` for loops.
|
|
63
|
+
|
|
64
|
+
### Kept a connection past its `with`
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
with smtp.connect() as connection:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
connection.send(report)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Raises `ValueError`, because the connection is closed.
|
|
74
|
+
This is a mistake in the calling code, not a mail failure, so it is not a `EpistoleError` and `except EpistoleError` does not swallow it.
|
|
75
|
+
A connection is one link, used once; to send again, call `connect()` again.
|
|
76
|
+
|
|
77
|
+
### Notebook, two cells, Graph
|
|
78
|
+
|
|
79
|
+
Cell one:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
connection = graph.connect()
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The token is acquired here, so a bad tenant id fails here rather than on the first send.
|
|
86
|
+
|
|
87
|
+
Cell two, an hour later:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
connection.send(message)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The connection refreshes its token through the credential you gave the backend, so an expired token is not an error.
|
|
94
|
+
Only a refresh that itself fails raises `AuthenticationError`, and the connection stays open either way.
|
|
95
|
+
Leaving the connection unclosed at the end of a notebook leaks nothing on Graph or Gmail.
|
|
96
|
+
On SMTP it leaves a socket open until the server times it out, which is why the loop above uses `with`.
|
|
97
|
+
|
|
98
|
+
### Threads
|
|
99
|
+
|
|
100
|
+
A backend is immutable and safe to share.
|
|
101
|
+
A connection is not: use one per thread, the same rule as a DB-API connection.
|
|
102
|
+
Epistole does not lock a connection for you; two threads on one connection is a bug in the caller.
|
|
103
|
+
|
|
104
|
+
### Markdown instead of HTML
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
note = (
|
|
108
|
+
Message(markdown="## Numbers\n\nSee the [dashboard](https://kpi.example).")
|
|
109
|
+
.subject("Numbers")
|
|
110
|
+
.to("boss@corp.example")
|
|
111
|
+
)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Needs the extra: `pip install "epistole[markdown]"`.
|
|
115
|
+
Without it, this line raises `ImportError` naming the extra.
|
|
116
|
+
The Markdown renders to HTML for clients that show it, and the source you wrote is the plain text for clients that do not.
|
|
117
|
+
Exactly one of `html=` or `markdown=` per message.
|
|
118
|
+
|
|
119
|
+
### Plain text only
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
smtp.send(
|
|
123
|
+
Message(text="Pipeline failed at 03:12. See run 4821.")
|
|
124
|
+
.subject("Pipeline failed")
|
|
125
|
+
.to("oncall@corp.example")
|
|
126
|
+
)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
No HTML part is made; the message is `text/plain` and every client renders it.
|
|
130
|
+
|
|
131
|
+
### A better plain-text part
|
|
132
|
+
|
|
133
|
+
Every HTML message carries plain text.
|
|
134
|
+
Epistole derives it with a small extractor of its own, which keeps links, marks list items, and drops the stylesheet.
|
|
135
|
+
To supply your own, pass `text=` and nothing is derived:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
Message(html=body, text=Path("weekly.txt").read_text(encoding="utf-8"))
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
To derive it with a library you prefer, pass `text_renderer=`, a callable from HTML to text.
|
|
142
|
+
It runs once, after Epistole has moved `data:` images out of the HTML, so no base64 lands in the text.
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from inscriptis import get_text
|
|
146
|
+
from inscriptis.model.config import ParserConfig
|
|
147
|
+
|
|
148
|
+
config = ParserConfig(display_links=True)
|
|
149
|
+
|
|
150
|
+
Message(html=body, text_renderer=lambda h: get_text(h, config))
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
`inscriptis` aligns table columns, which Epistole's extractor does not.
|
|
154
|
+
`html2text` works the same way through `HTML2Text().handle`; set `unicode_snob = True` on it or `Café` arrives as `Cafe`, and note its licence is GPL-3.0-or-later.
|
|
155
|
+
The default is exported as `epistole.html_to_text` if you want to wrap it.
|
|
156
|
+
|
|
157
|
+
## Choosing a backend
|
|
158
|
+
|
|
159
|
+
Epistole sends the same message through any backend, so choosing one is a deployment decision, not a code decision.
|
|
160
|
+
|
|
161
|
+
Use **SMTP** unless something stops you.
|
|
162
|
+
It works with every mail system, it carries the largest messages, and it sends exactly the MIME Epistole built.
|
|
163
|
+
|
|
164
|
+
Use **Graph** when your tenant has turned SMTP AUTH off, or when you need a retry hint on throttling.
|
|
165
|
+
Accept its 4 MB body limit before you choose it.
|
|
166
|
+
|
|
167
|
+
Use **Gmail** when you are already authenticated against a Google account and would rather not manage an SMTP credential.
|
|
168
|
+
|
|
169
|
+
| | SMTP | Gmail | Graph |
|
|
170
|
+
| --- | --- | --- | --- |
|
|
171
|
+
| Mail systems served | any | Google accounts | Exchange Online |
|
|
172
|
+
| Largest body | whole-message limit | whole-message limit | **4 MB, no path past it** |
|
|
173
|
+
| Largest message | server `SIZE`, 35 MB on a default Exchange Online tenant | 25 MB before encoding | 35 MB default, 1 MB to 150 MB configurable |
|
|
174
|
+
| Largest attachment | shares the message limit | shares the message limit | 150 MB, via upload session |
|
|
175
|
+
| Per-recipient refusals | visible | not expressible | not expressible |
|
|
176
|
+
| Retry hint | none | none documented | `Retry-After` |
|
|
177
|
+
| MIME you send | is what arrives | is what arrives | rebuilt by Exchange |
|
|
178
|
+
| Recipients per message | server policy | 500 | 500 |
|
|
179
|
+
| Credentials | anonymous, password, or OAuth | Google credentials | token credential |
|
|
180
|
+
|
|
181
|
+
Four things decide it.
|
|
182
|
+
|
|
183
|
+
**Body size.**
|
|
184
|
+
Graph caps the entire write request at 4 MB and has no chunked path for a message body, so a large embedded HTML report cannot be sent through it at all.
|
|
185
|
+
SMTP and Gmail measure against the whole message, so a body of several MB is routine on both.
|
|
186
|
+
If you send through Graph, attach the report as a file and keep the body small.
|
|
187
|
+
|
|
188
|
+
**Whether your tenant allows SMTP AUTH.**
|
|
189
|
+
On Exchange Online, security defaults and any policy that blocks basic authentication switch SMTP client submission off, and the trend runs one way.
|
|
190
|
+
Graph keeps working through all of it.
|
|
191
|
+
The per-mailbox SMTP AUTH setting overrides the organization setting, so one enabled mailbox is the documented workaround.
|
|
192
|
+
|
|
193
|
+
**Fidelity against features.**
|
|
194
|
+
SMTP and Gmail take the complete RFC 5322 message Epistole builds, so what you send is what arrives.
|
|
195
|
+
Graph takes a flat JSON array and Exchange serializes the MIME later, so Epistole can guarantee your `cid:` references resolve but not the MIME structure around them.
|
|
196
|
+
In exchange, Graph is the only backend that tells you how long to wait when it throttles you.
|
|
197
|
+
|
|
198
|
+
**What the permission costs.**
|
|
199
|
+
For a plain send the three are comparable: `SMTP.SendAsApp`, the `gmail.send` scope, `Mail.Send`.
|
|
200
|
+
Above 3 MB of attachment Graph needs a draft, which needs `Mail.ReadWrite`, which grants reading every message in scope.
|
|
201
|
+
Keep attachments under 3 MB if you send through Graph and a security reviewer will thank you.
|
|
202
|
+
|
|
203
|
+
`ConsoleBackend` and `MemoryBackend` are backends like any other; swapping one in is the same one-word change.
|
|
204
|
+
|
|
205
|
+
Limits quoted on 2026-09-08 and they move; re-check before relying on one.
|
|
206
|
+
Sources: [Graph request limits](https://learn.microsoft.com/en-us/graph/use-the-api), [Graph large attachments](https://learn.microsoft.com/en-us/graph/outlook-large-attachments), [Exchange Online limits](https://learn.microsoft.com/en-us/office365/servicedescriptions/exchange-online-service-description/exchange-online-limits), [Gmail sending limits](https://knowledge.workspace.google.com/admin/gmail/gmail-sending-limits-in-google-workspace), [Exchange rebuilds MIME](https://learn.microsoft.com/en-us/graph/outlook-things-to-know-about-send-mail), [SMTP `SIZE`, RFC 1870](https://datatracker.ietf.org/doc/html/rfc1870), [SMTP AUTH on Exchange Online](https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission).
|
|
207
|
+
Fuller working is in `docs/research/send-boundary-semantics.md` and `docs/research/attachment-and-inline-rules.md`.
|
|
208
|
+
|
|
209
|
+
## About the name
|
|
210
|
+
|
|
211
|
+
An epistole was a stone marker set at crossroads and roadsides in ancient Greece.
|
|
212
|
+
Travelers used them to tell which road led where.
|
|
213
|
+
The name fits a library whose job is to take one message and direct it down whichever road you have chosen.
|
|
214
|
+
|
|
215
|
+
## Credit
|
|
216
|
+
|
|
217
|
+
`epistole` is inspired by [blastula](https://github.com/rstudio/blastula), an R package for composing and sending email.
|
|
218
|
+
It is not a port, and the API does not mirror blastula's.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "epistole"
|
|
3
|
+
version = "0.0.2"
|
|
4
|
+
description = "A single Python API for sending email, regardless of the backend behind it."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"email",
|
|
11
|
+
"gmail",
|
|
12
|
+
"microsoft-graph",
|
|
13
|
+
"smtp",
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Topic :: Communications :: Email",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[[project.authors]]
|
|
25
|
+
name = "Ozan Ozbeker"
|
|
26
|
+
email = "github@ozanozbeker.com"
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Changelog = "https://github.com/ozanozbeker/epistole/blob/main/CHANGELOG.md"
|
|
30
|
+
Homepage = "https://github.com/ozanozbeker/epistole"
|
|
31
|
+
Issues = "https://github.com/ozanozbeker/epistole/issues"
|
|
32
|
+
Repository = "https://github.com/ozanozbeker/epistole"
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
all = ["epistole[gmail,graph,markdown]"]
|
|
36
|
+
gmail = [
|
|
37
|
+
"google-auth>=2.57",
|
|
38
|
+
"httpx2>=2.12,<3",
|
|
39
|
+
]
|
|
40
|
+
graph = [
|
|
41
|
+
"httpx2>=2.12,<3",
|
|
42
|
+
"msal>=1.38",
|
|
43
|
+
]
|
|
44
|
+
markdown = ["markdown-it-py>=4.2"]
|
|
45
|
+
|
|
46
|
+
[dependency-groups]
|
|
47
|
+
dev = [
|
|
48
|
+
"prek>=0.5.2",
|
|
49
|
+
"pyrefly>=1.2.0",
|
|
50
|
+
"pytest>=9.1.1",
|
|
51
|
+
"ruff>=0.16.6",
|
|
52
|
+
"rumdl>=0.2.68",
|
|
53
|
+
"tombi>=1.5.2",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
[build-system]
|
|
57
|
+
requires = ["uv_build>=0.12.10,<0.13.0"]
|
|
58
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "epistole"
|
|
3
|
+
version = "0.0.2"
|
|
4
|
+
description = "A single Python API for sending email, regardless of the backend behind it."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Ozan Ozbeker", email = "github@ozanozbeker.com" }]
|
|
10
|
+
keywords = ["email", "gmail", "microsoft-graph", "smtp"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Programming Language :: Python :: 3.13",
|
|
15
|
+
"Topic :: Communications :: Email",
|
|
16
|
+
"Typing :: Typed",
|
|
17
|
+
]
|
|
18
|
+
dependencies = []
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Changelog = "https://github.com/ozanozbeker/epistole/blob/main/CHANGELOG.md"
|
|
22
|
+
Homepage = "https://github.com/ozanozbeker/epistole"
|
|
23
|
+
Issues = "https://github.com/ozanozbeker/epistole/issues"
|
|
24
|
+
Repository = "https://github.com/ozanozbeker/epistole"
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
all = ["epistole[gmail,graph,markdown]"]
|
|
28
|
+
gmail = ["google-auth>=2.57", "httpx2>=2.12,<3"]
|
|
29
|
+
graph = ["httpx2>=2.12,<3", "msal>=1.38"]
|
|
30
|
+
markdown = ["markdown-it-py>=4.2"]
|
|
31
|
+
|
|
32
|
+
[dependency-groups]
|
|
33
|
+
dev = [
|
|
34
|
+
"prek>=0.5.2",
|
|
35
|
+
"pyrefly>=1.2.0",
|
|
36
|
+
"pytest>=9.1.1",
|
|
37
|
+
"ruff>=0.16.6",
|
|
38
|
+
"rumdl>=0.2.68",
|
|
39
|
+
"tombi>=1.5.2",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["uv_build>=0.12.10,<0.13.0"]
|
|
44
|
+
build-backend = "uv_build"
|
|
File without changes
|
|
File without changes
|