mailengin 0.1.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.
- mailengin-0.1.0/.gitignore +8 -0
- mailengin-0.1.0/CHANGELOG.md +23 -0
- mailengin-0.1.0/LICENSE +9 -0
- mailengin-0.1.0/PKG-INFO +322 -0
- mailengin-0.1.0/README.md +290 -0
- mailengin-0.1.0/pyproject.toml +47 -0
- mailengin-0.1.0/src/mailengin/__init__.py +22 -0
- mailengin-0.1.0/src/mailengin/client.py +145 -0
- mailengin-0.1.0/src/mailengin/emails.py +105 -0
- mailengin-0.1.0/src/mailengin/errors.py +27 -0
- mailengin-0.1.0/src/mailengin/models.py +54 -0
- mailengin-0.1.0/src/mailengin/py.typed +1 -0
- mailengin-0.1.0/tests/test_mailengin.py +135 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this package will be documented here.
|
|
4
|
+
|
|
5
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## Unreleased
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Initial Python SDK with synchronous and asynchronous clients.
|
|
12
|
+
- Typed single and personalized bulk email operations.
|
|
13
|
+
- Template, raw HTML, variables, sender override, and reply-routing support.
|
|
14
|
+
- Structured API, timeout, malformed-response, and network errors.
|
|
15
|
+
- HTTPX transport injection, tests, CI, and trusted PyPI publishing.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Make release tag validation safe for Bash quoting on GitHub Actions.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Make release tag validation safe for Bash quoting on GitHub Actions.
|
mailengin-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MailEngin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
mailengin-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mailengin
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the MailEngin Email API
|
|
5
|
+
Project-URL: Homepage, https://mailengin.app
|
|
6
|
+
Project-URL: Repository, https://github.com/mailengin/mailengin-python-sdk
|
|
7
|
+
Project-URL: Issues, https://github.com/mailengin/mailengin-python-sdk/issues
|
|
8
|
+
Project-URL: Documentation, https://mailengin.app/dashboard/docs
|
|
9
|
+
Author: MailEngin
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 MailEngin
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
17
|
+
|
|
18
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Keywords: email,email-api,mailengin,transactional-email
|
|
21
|
+
Classifier: Development Status :: 3 - Alpha
|
|
22
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
23
|
+
Classifier: Programming Language :: Python :: 3
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Python: >=3.10
|
|
29
|
+
Requires-Dist: httpx<1,>=0.27
|
|
30
|
+
Requires-Dist: typing-extensions>=4.6
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# MailEngin Python SDK
|
|
34
|
+
|
|
35
|
+
[](https://www.python.org/)
|
|
36
|
+
[](https://mypy.readthedocs.io/)
|
|
37
|
+
[](./LICENSE)
|
|
38
|
+
|
|
39
|
+
The official Python SDK for sending transactional email through [MailEngin](https://mailengin.app). It provides synchronous and asynchronous clients, typed dataclass models, configurable timeouts, and structured errors.
|
|
40
|
+
|
|
41
|
+
> [!IMPORTANT]
|
|
42
|
+
> This package is for server-side applications only. Never expose a MailEngin API key in browser, mobile, desktop, or other client-distributed code.
|
|
43
|
+
|
|
44
|
+
## Requirements
|
|
45
|
+
|
|
46
|
+
- Python 3.10 or newer
|
|
47
|
+
- A MailEngin API key
|
|
48
|
+
- A verified sending domain
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
python -m pip install mailengin
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
For a specific version:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
python -m pip install "mailengin==0.1.0"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Before You Send
|
|
63
|
+
|
|
64
|
+
1. [Verify a sending domain](https://mailengin.app/dashboard/domains).
|
|
65
|
+
2. [Create an API key](https://mailengin.app/dashboard/api-keys) and save the full secret.
|
|
66
|
+
3. [Create and publish a Developer Template](https://mailengin.app/dashboard/dev-templates).
|
|
67
|
+
4. Copy the template API name, such as `welcome-email`.
|
|
68
|
+
|
|
69
|
+
Store the API key in a server-side environment variable:
|
|
70
|
+
|
|
71
|
+
```env
|
|
72
|
+
MAILENGIN_API_KEY=re_your_full_secret_key
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
MailEngin displays the full key only once. A masked key cannot authenticate requests.
|
|
76
|
+
|
|
77
|
+
## Quick Start
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
import os
|
|
81
|
+
|
|
82
|
+
from mailengin import MailEngin, SendEmailRequest
|
|
83
|
+
|
|
84
|
+
with MailEngin(os.environ["MAILENGIN_API_KEY"]) as client:
|
|
85
|
+
email = client.emails.send(
|
|
86
|
+
SendEmailRequest(
|
|
87
|
+
to="user@example.com",
|
|
88
|
+
from_email="hello@yourdomain.com",
|
|
89
|
+
template_name="welcome-email",
|
|
90
|
+
variables={"first_name": "Asha"},
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
print(email.id)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The published template supplies the subject and HTML. Values in `variables` replace matching template variables such as `{{first_name}}`.
|
|
98
|
+
|
|
99
|
+
## Async Client
|
|
100
|
+
|
|
101
|
+
`AsyncMailEngin` exposes the same email methods as coroutines and uses `httpx.AsyncClient` internally:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
import asyncio
|
|
105
|
+
import os
|
|
106
|
+
|
|
107
|
+
from mailengin import AsyncMailEngin, SendEmailRequest
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def main() -> None:
|
|
111
|
+
async with AsyncMailEngin(os.environ["MAILENGIN_API_KEY"]) as client:
|
|
112
|
+
email = await client.emails.send(
|
|
113
|
+
SendEmailRequest(
|
|
114
|
+
to="user@example.com",
|
|
115
|
+
template_name="welcome-email",
|
|
116
|
+
variables={"first_name": "Asha"},
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
print(email.id)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
asyncio.run(main())
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Cancel the surrounding asyncio task to cancel an in-flight asynchronous request.
|
|
126
|
+
|
|
127
|
+
## Send One Email
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
request = SendEmailRequest(
|
|
131
|
+
to="customer@example.com",
|
|
132
|
+
from_email="hello@yourdomain.com",
|
|
133
|
+
template_name="account-verification",
|
|
134
|
+
variables={
|
|
135
|
+
"first_name": "Asha",
|
|
136
|
+
"verification_url": "https://yourapp.com/verify/token",
|
|
137
|
+
},
|
|
138
|
+
reply_to_mailengin=True,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
email = client.emails.send(request)
|
|
142
|
+
print(email.id, email.from_email, email.created_at)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Send request fields
|
|
146
|
+
|
|
147
|
+
| Field | Type | Required | Description |
|
|
148
|
+
| --- | --- | --- | --- |
|
|
149
|
+
| `to` | `str` | Yes | Recipient email address. |
|
|
150
|
+
| `template_name` | `str` | Recommended | Published template API name or exact display name. |
|
|
151
|
+
| `template_id` | `str` | No | Legacy template identifier. Prefer `template_name`. |
|
|
152
|
+
| `variables` | `dict[str, Any]` | No | Values used to render template variables. |
|
|
153
|
+
| `subject` | `str` | Raw HTML only | Template subject override, or required subject for raw HTML. |
|
|
154
|
+
| `from_email` | `str` | Recommended | Sender on a verified domain authorized for the API key. |
|
|
155
|
+
| `html` | `str` | Advanced | Raw HTML used when no template is supplied. |
|
|
156
|
+
| `reply_to_mailengin` | `bool` | No | Route recipient replies into the MailEngin inbox. |
|
|
157
|
+
|
|
158
|
+
Exactly one content source is required: `template_name`, `template_id`, or `html`. Raw HTML sends also require `subject`.
|
|
159
|
+
|
|
160
|
+
## Send Personalized Bulk Email
|
|
161
|
+
|
|
162
|
+
Bulk requests support up to 1,000 recipients. Request-level variables apply to every recipient; recipient variables take precedence.
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from mailengin import BulkRecipient, SendBulkEmailRequest
|
|
166
|
+
|
|
167
|
+
job = client.emails.send_bulk(
|
|
168
|
+
SendBulkEmailRequest(
|
|
169
|
+
to=[
|
|
170
|
+
BulkRecipient(
|
|
171
|
+
email="asha@example.com",
|
|
172
|
+
variables={"first_name": "Asha"},
|
|
173
|
+
),
|
|
174
|
+
BulkRecipient(
|
|
175
|
+
email="ben@example.com",
|
|
176
|
+
variables={"first_name": "Ben"},
|
|
177
|
+
),
|
|
178
|
+
],
|
|
179
|
+
from_email="hello@yourdomain.com",
|
|
180
|
+
template_name="product-update",
|
|
181
|
+
variables={"product_name": "MailEngin"},
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
print(job.job_id, job.queued_count)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
For the same content without recipient-specific variables, use strings:
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
job = client.emails.send_bulk(
|
|
192
|
+
SendBulkEmailRequest(
|
|
193
|
+
to=["a@example.com", "b@example.com"],
|
|
194
|
+
template_name="maintenance-notice",
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
A successful bulk response confirms that recipients were queued. It is not a guarantee that every message was delivered.
|
|
200
|
+
|
|
201
|
+
## Send Raw HTML
|
|
202
|
+
|
|
203
|
+
Published templates are recommended for reusable product email. For a one-off message, provide both `subject` and `html`:
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
email = client.emails.send(
|
|
207
|
+
SendEmailRequest(
|
|
208
|
+
to="user@example.com",
|
|
209
|
+
from_email="reports@yourdomain.com",
|
|
210
|
+
subject="Your report is ready",
|
|
211
|
+
html="<h1>Report ready</h1><p>You can download it now.</p>",
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Sender Selection
|
|
217
|
+
|
|
218
|
+
MailEngin resolves the sender in this order:
|
|
219
|
+
|
|
220
|
+
1. `from_email` supplied in the request.
|
|
221
|
+
2. Sender saved in the published Developer Template.
|
|
222
|
+
3. `noreply@<authorized-domain>` fallback.
|
|
223
|
+
|
|
224
|
+
The sender domain must be verified and authorized for the API key. Set the sender in the template or request for predictable production sends.
|
|
225
|
+
|
|
226
|
+
## Error Handling
|
|
227
|
+
|
|
228
|
+
API, timeout, malformed-response, and network failures raise `MailEnginError`:
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
from mailengin import MailEnginError, SendEmailRequest
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
client.emails.send(
|
|
235
|
+
SendEmailRequest(
|
|
236
|
+
to="user@example.com",
|
|
237
|
+
template_name="welcome-email",
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
except MailEnginError as error:
|
|
241
|
+
print(error)
|
|
242
|
+
print(error.status) # HTTP status, when available
|
|
243
|
+
print(error.code) # Machine-readable error code
|
|
244
|
+
print(error.request_id) # Include when contacting support
|
|
245
|
+
print(error.retry_after) # Seconds supplied with HTTP 429
|
|
246
|
+
print(error.body) # Parsed JSON or response text
|
|
247
|
+
print(error.is_retryable)
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
`is_retryable` is true for network errors, timeouts, HTTP `408`, HTTP `429`, and `5xx` responses. The SDK never retries sends automatically because a retry could create a duplicate email until idempotency keys are supported.
|
|
251
|
+
|
|
252
|
+
## Configuration
|
|
253
|
+
|
|
254
|
+
```python
|
|
255
|
+
client = MailEngin(
|
|
256
|
+
os.environ["MAILENGIN_API_KEY"],
|
|
257
|
+
base_url="https://api.mailengin.app",
|
|
258
|
+
timeout=15.0,
|
|
259
|
+
)
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
| Option | Default | Description |
|
|
263
|
+
| --- | --- | --- |
|
|
264
|
+
| `api_key` | None | Full server-side MailEngin API key. |
|
|
265
|
+
| `base_url` | `https://api.mailengin.app` | Override for local, test, or dedicated environments. |
|
|
266
|
+
| `timeout` | `30.0` | Request timeout in seconds. |
|
|
267
|
+
| `http_client` | New HTTPX client | Injectable `httpx.Client` or `httpx.AsyncClient`. |
|
|
268
|
+
|
|
269
|
+
When you inject an HTTP client, your application owns its lifecycle. Otherwise, use the client as a context manager or call `close()` when finished.
|
|
270
|
+
|
|
271
|
+
## Testing With an Injected Transport
|
|
272
|
+
|
|
273
|
+
No real API key is required in unit tests. Inject an HTTPX client backed by `MockTransport`:
|
|
274
|
+
|
|
275
|
+
```python
|
|
276
|
+
import httpx
|
|
277
|
+
|
|
278
|
+
from mailengin import MailEngin
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
282
|
+
assert request.headers["authorization"] == "Bearer test_key"
|
|
283
|
+
return httpx.Response(
|
|
284
|
+
200,
|
|
285
|
+
json={
|
|
286
|
+
"id": "email_123",
|
|
287
|
+
"from": "hello@example.com",
|
|
288
|
+
"to": "user@example.com",
|
|
289
|
+
"template_name": "welcome-email",
|
|
290
|
+
"created_at": "2026-08-31T12:00:00Z",
|
|
291
|
+
},
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
http_client = httpx.Client(transport=httpx.MockTransport(handler))
|
|
296
|
+
client = MailEngin("test_key", http_client=http_client)
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
## Development
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
python -m pip install -e . pytest pytest-asyncio ruff mypy build twine
|
|
303
|
+
ruff check .
|
|
304
|
+
mypy src
|
|
305
|
+
pytest
|
|
306
|
+
python -m build
|
|
307
|
+
python -m twine check dist/*
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md) for contribution rules and [PUBLISHING.md](./PUBLISHING.md) for maintainer release instructions.
|
|
311
|
+
|
|
312
|
+
## Resources
|
|
313
|
+
|
|
314
|
+
- [MailEngin API documentation](https://mailengin.app/dashboard/docs)
|
|
315
|
+
- [Developer Templates](https://mailengin.app/dashboard/dev-templates)
|
|
316
|
+
- [API keys](https://mailengin.app/dashboard/api-keys)
|
|
317
|
+
- [Security policy](./SECURITY.md)
|
|
318
|
+
- [Changelog](./CHANGELOG.md)
|
|
319
|
+
|
|
320
|
+
## License
|
|
321
|
+
|
|
322
|
+
Released under the [MIT License](./LICENSE). Copyright 2026 MailEngin.
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# MailEngin Python SDK
|
|
2
|
+
|
|
3
|
+
[](https://www.python.org/)
|
|
4
|
+
[](https://mypy.readthedocs.io/)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
The official Python SDK for sending transactional email through [MailEngin](https://mailengin.app). It provides synchronous and asynchronous clients, typed dataclass models, configurable timeouts, and structured errors.
|
|
8
|
+
|
|
9
|
+
> [!IMPORTANT]
|
|
10
|
+
> This package is for server-side applications only. Never expose a MailEngin API key in browser, mobile, desktop, or other client-distributed code.
|
|
11
|
+
|
|
12
|
+
## Requirements
|
|
13
|
+
|
|
14
|
+
- Python 3.10 or newer
|
|
15
|
+
- A MailEngin API key
|
|
16
|
+
- A verified sending domain
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
python -m pip install mailengin
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
For a specific version:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
python -m pip install "mailengin==0.1.0"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Before You Send
|
|
31
|
+
|
|
32
|
+
1. [Verify a sending domain](https://mailengin.app/dashboard/domains).
|
|
33
|
+
2. [Create an API key](https://mailengin.app/dashboard/api-keys) and save the full secret.
|
|
34
|
+
3. [Create and publish a Developer Template](https://mailengin.app/dashboard/dev-templates).
|
|
35
|
+
4. Copy the template API name, such as `welcome-email`.
|
|
36
|
+
|
|
37
|
+
Store the API key in a server-side environment variable:
|
|
38
|
+
|
|
39
|
+
```env
|
|
40
|
+
MAILENGIN_API_KEY=re_your_full_secret_key
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
MailEngin displays the full key only once. A masked key cannot authenticate requests.
|
|
44
|
+
|
|
45
|
+
## Quick Start
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import os
|
|
49
|
+
|
|
50
|
+
from mailengin import MailEngin, SendEmailRequest
|
|
51
|
+
|
|
52
|
+
with MailEngin(os.environ["MAILENGIN_API_KEY"]) as client:
|
|
53
|
+
email = client.emails.send(
|
|
54
|
+
SendEmailRequest(
|
|
55
|
+
to="user@example.com",
|
|
56
|
+
from_email="hello@yourdomain.com",
|
|
57
|
+
template_name="welcome-email",
|
|
58
|
+
variables={"first_name": "Asha"},
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
print(email.id)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The published template supplies the subject and HTML. Values in `variables` replace matching template variables such as `{{first_name}}`.
|
|
66
|
+
|
|
67
|
+
## Async Client
|
|
68
|
+
|
|
69
|
+
`AsyncMailEngin` exposes the same email methods as coroutines and uses `httpx.AsyncClient` internally:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import asyncio
|
|
73
|
+
import os
|
|
74
|
+
|
|
75
|
+
from mailengin import AsyncMailEngin, SendEmailRequest
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def main() -> None:
|
|
79
|
+
async with AsyncMailEngin(os.environ["MAILENGIN_API_KEY"]) as client:
|
|
80
|
+
email = await client.emails.send(
|
|
81
|
+
SendEmailRequest(
|
|
82
|
+
to="user@example.com",
|
|
83
|
+
template_name="welcome-email",
|
|
84
|
+
variables={"first_name": "Asha"},
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
print(email.id)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
asyncio.run(main())
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Cancel the surrounding asyncio task to cancel an in-flight asynchronous request.
|
|
94
|
+
|
|
95
|
+
## Send One Email
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
request = SendEmailRequest(
|
|
99
|
+
to="customer@example.com",
|
|
100
|
+
from_email="hello@yourdomain.com",
|
|
101
|
+
template_name="account-verification",
|
|
102
|
+
variables={
|
|
103
|
+
"first_name": "Asha",
|
|
104
|
+
"verification_url": "https://yourapp.com/verify/token",
|
|
105
|
+
},
|
|
106
|
+
reply_to_mailengin=True,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
email = client.emails.send(request)
|
|
110
|
+
print(email.id, email.from_email, email.created_at)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Send request fields
|
|
114
|
+
|
|
115
|
+
| Field | Type | Required | Description |
|
|
116
|
+
| --- | --- | --- | --- |
|
|
117
|
+
| `to` | `str` | Yes | Recipient email address. |
|
|
118
|
+
| `template_name` | `str` | Recommended | Published template API name or exact display name. |
|
|
119
|
+
| `template_id` | `str` | No | Legacy template identifier. Prefer `template_name`. |
|
|
120
|
+
| `variables` | `dict[str, Any]` | No | Values used to render template variables. |
|
|
121
|
+
| `subject` | `str` | Raw HTML only | Template subject override, or required subject for raw HTML. |
|
|
122
|
+
| `from_email` | `str` | Recommended | Sender on a verified domain authorized for the API key. |
|
|
123
|
+
| `html` | `str` | Advanced | Raw HTML used when no template is supplied. |
|
|
124
|
+
| `reply_to_mailengin` | `bool` | No | Route recipient replies into the MailEngin inbox. |
|
|
125
|
+
|
|
126
|
+
Exactly one content source is required: `template_name`, `template_id`, or `html`. Raw HTML sends also require `subject`.
|
|
127
|
+
|
|
128
|
+
## Send Personalized Bulk Email
|
|
129
|
+
|
|
130
|
+
Bulk requests support up to 1,000 recipients. Request-level variables apply to every recipient; recipient variables take precedence.
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from mailengin import BulkRecipient, SendBulkEmailRequest
|
|
134
|
+
|
|
135
|
+
job = client.emails.send_bulk(
|
|
136
|
+
SendBulkEmailRequest(
|
|
137
|
+
to=[
|
|
138
|
+
BulkRecipient(
|
|
139
|
+
email="asha@example.com",
|
|
140
|
+
variables={"first_name": "Asha"},
|
|
141
|
+
),
|
|
142
|
+
BulkRecipient(
|
|
143
|
+
email="ben@example.com",
|
|
144
|
+
variables={"first_name": "Ben"},
|
|
145
|
+
),
|
|
146
|
+
],
|
|
147
|
+
from_email="hello@yourdomain.com",
|
|
148
|
+
template_name="product-update",
|
|
149
|
+
variables={"product_name": "MailEngin"},
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
print(job.job_id, job.queued_count)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
For the same content without recipient-specific variables, use strings:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
job = client.emails.send_bulk(
|
|
160
|
+
SendBulkEmailRequest(
|
|
161
|
+
to=["a@example.com", "b@example.com"],
|
|
162
|
+
template_name="maintenance-notice",
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
A successful bulk response confirms that recipients were queued. It is not a guarantee that every message was delivered.
|
|
168
|
+
|
|
169
|
+
## Send Raw HTML
|
|
170
|
+
|
|
171
|
+
Published templates are recommended for reusable product email. For a one-off message, provide both `subject` and `html`:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
email = client.emails.send(
|
|
175
|
+
SendEmailRequest(
|
|
176
|
+
to="user@example.com",
|
|
177
|
+
from_email="reports@yourdomain.com",
|
|
178
|
+
subject="Your report is ready",
|
|
179
|
+
html="<h1>Report ready</h1><p>You can download it now.</p>",
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Sender Selection
|
|
185
|
+
|
|
186
|
+
MailEngin resolves the sender in this order:
|
|
187
|
+
|
|
188
|
+
1. `from_email` supplied in the request.
|
|
189
|
+
2. Sender saved in the published Developer Template.
|
|
190
|
+
3. `noreply@<authorized-domain>` fallback.
|
|
191
|
+
|
|
192
|
+
The sender domain must be verified and authorized for the API key. Set the sender in the template or request for predictable production sends.
|
|
193
|
+
|
|
194
|
+
## Error Handling
|
|
195
|
+
|
|
196
|
+
API, timeout, malformed-response, and network failures raise `MailEnginError`:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
from mailengin import MailEnginError, SendEmailRequest
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
client.emails.send(
|
|
203
|
+
SendEmailRequest(
|
|
204
|
+
to="user@example.com",
|
|
205
|
+
template_name="welcome-email",
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
except MailEnginError as error:
|
|
209
|
+
print(error)
|
|
210
|
+
print(error.status) # HTTP status, when available
|
|
211
|
+
print(error.code) # Machine-readable error code
|
|
212
|
+
print(error.request_id) # Include when contacting support
|
|
213
|
+
print(error.retry_after) # Seconds supplied with HTTP 429
|
|
214
|
+
print(error.body) # Parsed JSON or response text
|
|
215
|
+
print(error.is_retryable)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`is_retryable` is true for network errors, timeouts, HTTP `408`, HTTP `429`, and `5xx` responses. The SDK never retries sends automatically because a retry could create a duplicate email until idempotency keys are supported.
|
|
219
|
+
|
|
220
|
+
## Configuration
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
client = MailEngin(
|
|
224
|
+
os.environ["MAILENGIN_API_KEY"],
|
|
225
|
+
base_url="https://api.mailengin.app",
|
|
226
|
+
timeout=15.0,
|
|
227
|
+
)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
| Option | Default | Description |
|
|
231
|
+
| --- | --- | --- |
|
|
232
|
+
| `api_key` | None | Full server-side MailEngin API key. |
|
|
233
|
+
| `base_url` | `https://api.mailengin.app` | Override for local, test, or dedicated environments. |
|
|
234
|
+
| `timeout` | `30.0` | Request timeout in seconds. |
|
|
235
|
+
| `http_client` | New HTTPX client | Injectable `httpx.Client` or `httpx.AsyncClient`. |
|
|
236
|
+
|
|
237
|
+
When you inject an HTTP client, your application owns its lifecycle. Otherwise, use the client as a context manager or call `close()` when finished.
|
|
238
|
+
|
|
239
|
+
## Testing With an Injected Transport
|
|
240
|
+
|
|
241
|
+
No real API key is required in unit tests. Inject an HTTPX client backed by `MockTransport`:
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
import httpx
|
|
245
|
+
|
|
246
|
+
from mailengin import MailEngin
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
250
|
+
assert request.headers["authorization"] == "Bearer test_key"
|
|
251
|
+
return httpx.Response(
|
|
252
|
+
200,
|
|
253
|
+
json={
|
|
254
|
+
"id": "email_123",
|
|
255
|
+
"from": "hello@example.com",
|
|
256
|
+
"to": "user@example.com",
|
|
257
|
+
"template_name": "welcome-email",
|
|
258
|
+
"created_at": "2026-08-31T12:00:00Z",
|
|
259
|
+
},
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
http_client = httpx.Client(transport=httpx.MockTransport(handler))
|
|
264
|
+
client = MailEngin("test_key", http_client=http_client)
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## Development
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
python -m pip install -e . pytest pytest-asyncio ruff mypy build twine
|
|
271
|
+
ruff check .
|
|
272
|
+
mypy src
|
|
273
|
+
pytest
|
|
274
|
+
python -m build
|
|
275
|
+
python -m twine check dist/*
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md) for contribution rules and [PUBLISHING.md](./PUBLISHING.md) for maintainer release instructions.
|
|
279
|
+
|
|
280
|
+
## Resources
|
|
281
|
+
|
|
282
|
+
- [MailEngin API documentation](https://mailengin.app/dashboard/docs)
|
|
283
|
+
- [Developer Templates](https://mailengin.app/dashboard/dev-templates)
|
|
284
|
+
- [API keys](https://mailengin.app/dashboard/api-keys)
|
|
285
|
+
- [Security policy](./SECURITY.md)
|
|
286
|
+
- [Changelog](./CHANGELOG.md)
|
|
287
|
+
|
|
288
|
+
## License
|
|
289
|
+
|
|
290
|
+
Released under the [MIT License](./LICENSE). Copyright 2026 MailEngin.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mailengin"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the MailEngin Email API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [{ name = "MailEngin" }]
|
|
13
|
+
keywords = ["mailengin", "email", "transactional-email", "email-api"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = ["httpx>=0.27,<1", "typing-extensions>=4.6"]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://mailengin.app"
|
|
27
|
+
Repository = "https://github.com/mailengin/mailengin-python-sdk"
|
|
28
|
+
Issues = "https://github.com/mailengin/mailengin-python-sdk/issues"
|
|
29
|
+
Documentation = "https://mailengin.app/dashboard/docs"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/mailengin"]
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.sdist]
|
|
35
|
+
include = ["src/mailengin", "tests", "README.md", "LICENSE", "CHANGELOG.md", "pyproject.toml"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|
|
39
|
+
|
|
40
|
+
[tool.ruff]
|
|
41
|
+
line-length = 100
|
|
42
|
+
target-version = "py310"
|
|
43
|
+
|
|
44
|
+
[tool.mypy]
|
|
45
|
+
python_version = "3.10"
|
|
46
|
+
strict = true
|
|
47
|
+
packages = ["mailengin"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from .client import AsyncMailEngin, MailEngin
|
|
2
|
+
from .errors import MailEnginError
|
|
3
|
+
from .models import (
|
|
4
|
+
BulkRecipient,
|
|
5
|
+
SendBulkEmailRequest,
|
|
6
|
+
SendBulkEmailResponse,
|
|
7
|
+
SendEmailRequest,
|
|
8
|
+
SendEmailResponse,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AsyncMailEngin",
|
|
13
|
+
"BulkRecipient",
|
|
14
|
+
"MailEngin",
|
|
15
|
+
"MailEnginError",
|
|
16
|
+
"SendBulkEmailRequest",
|
|
17
|
+
"SendBulkEmailResponse",
|
|
18
|
+
"SendEmailRequest",
|
|
19
|
+
"SendEmailResponse",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
from typing_extensions import Self
|
|
6
|
+
|
|
7
|
+
from .emails import AsyncEmails, Emails
|
|
8
|
+
from .errors import MailEnginError
|
|
9
|
+
|
|
10
|
+
DEFAULT_BASE_URL = "https://api.mailengin.app"
|
|
11
|
+
DEFAULT_TIMEOUT = 30.0
|
|
12
|
+
USER_AGENT = "mailengin-python/0.1.0"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _api_error(response: httpx.Response) -> MailEnginError:
|
|
16
|
+
raw = response.text
|
|
17
|
+
try:
|
|
18
|
+
body: Any = response.json() if raw else None
|
|
19
|
+
except json.JSONDecodeError:
|
|
20
|
+
body = raw
|
|
21
|
+
message = body.get("message") if isinstance(body, dict) else None
|
|
22
|
+
code = body.get("code") if isinstance(body, dict) else None
|
|
23
|
+
retry_after: float | None = None
|
|
24
|
+
try:
|
|
25
|
+
retry_after = float(response.headers["retry-after"])
|
|
26
|
+
except (KeyError, ValueError):
|
|
27
|
+
pass
|
|
28
|
+
return MailEnginError(
|
|
29
|
+
message or f"MailEngin API request failed with status {response.status_code}.",
|
|
30
|
+
status=response.status_code,
|
|
31
|
+
code=code if isinstance(code, str) else None,
|
|
32
|
+
request_id=response.headers.get("x-request-id"),
|
|
33
|
+
retry_after=retry_after,
|
|
34
|
+
body=body,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MailEngin:
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
api_key: str,
|
|
42
|
+
*,
|
|
43
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
44
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
45
|
+
http_client: httpx.Client | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
if not api_key.strip():
|
|
48
|
+
raise ValueError("MailEngin requires a non-empty api_key.")
|
|
49
|
+
if timeout <= 0:
|
|
50
|
+
raise ValueError("MailEngin timeout must be positive.")
|
|
51
|
+
self._client = http_client or httpx.Client(timeout=timeout)
|
|
52
|
+
self._owns_client = http_client is None
|
|
53
|
+
self._api_key = api_key.strip()
|
|
54
|
+
self._base_url = base_url.rstrip("/")
|
|
55
|
+
self.emails = Emails(self._post)
|
|
56
|
+
|
|
57
|
+
def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
58
|
+
try:
|
|
59
|
+
response = self._client.post(
|
|
60
|
+
f"{self._base_url}{path}",
|
|
61
|
+
json=body,
|
|
62
|
+
headers={
|
|
63
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
64
|
+
"Accept": "application/json",
|
|
65
|
+
"User-Agent": USER_AGENT,
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
except httpx.TimeoutException as error:
|
|
69
|
+
raise MailEnginError("MailEngin request timed out.", code="request_timeout") from error
|
|
70
|
+
except httpx.RequestError as error:
|
|
71
|
+
raise MailEnginError("Unable to reach the MailEngin API.", code="network_error") from error
|
|
72
|
+
if not response.is_success:
|
|
73
|
+
raise _api_error(response)
|
|
74
|
+
try:
|
|
75
|
+
data = response.json()
|
|
76
|
+
except json.JSONDecodeError as error:
|
|
77
|
+
raise MailEnginError("MailEngin API returned invalid JSON.", code="invalid_response") from error
|
|
78
|
+
if not isinstance(data, dict):
|
|
79
|
+
raise MailEnginError("MailEngin API returned an invalid response.", code="invalid_response")
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
def close(self) -> None:
|
|
83
|
+
if self._owns_client:
|
|
84
|
+
self._client.close()
|
|
85
|
+
|
|
86
|
+
def __enter__(self) -> Self:
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def __exit__(self, *_: object) -> None:
|
|
90
|
+
self.close()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class AsyncMailEngin:
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
api_key: str,
|
|
97
|
+
*,
|
|
98
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
99
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
100
|
+
http_client: httpx.AsyncClient | None = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
if not api_key.strip():
|
|
103
|
+
raise ValueError("AsyncMailEngin requires a non-empty api_key.")
|
|
104
|
+
if timeout <= 0:
|
|
105
|
+
raise ValueError("AsyncMailEngin timeout must be positive.")
|
|
106
|
+
self._client = http_client or httpx.AsyncClient(timeout=timeout)
|
|
107
|
+
self._owns_client = http_client is None
|
|
108
|
+
self._api_key = api_key.strip()
|
|
109
|
+
self._base_url = base_url.rstrip("/")
|
|
110
|
+
self.emails = AsyncEmails(self._post)
|
|
111
|
+
|
|
112
|
+
async def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
113
|
+
try:
|
|
114
|
+
response = await self._client.post(
|
|
115
|
+
f"{self._base_url}{path}",
|
|
116
|
+
json=body,
|
|
117
|
+
headers={
|
|
118
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
119
|
+
"Accept": "application/json",
|
|
120
|
+
"User-Agent": USER_AGENT,
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
except httpx.TimeoutException as error:
|
|
124
|
+
raise MailEnginError("MailEngin request timed out.", code="request_timeout") from error
|
|
125
|
+
except httpx.RequestError as error:
|
|
126
|
+
raise MailEnginError("Unable to reach the MailEngin API.", code="network_error") from error
|
|
127
|
+
if not response.is_success:
|
|
128
|
+
raise _api_error(response)
|
|
129
|
+
try:
|
|
130
|
+
data = response.json()
|
|
131
|
+
except json.JSONDecodeError as error:
|
|
132
|
+
raise MailEnginError("MailEngin API returned invalid JSON.", code="invalid_response") from error
|
|
133
|
+
if not isinstance(data, dict):
|
|
134
|
+
raise MailEnginError("MailEngin API returned an invalid response.", code="invalid_response")
|
|
135
|
+
return data
|
|
136
|
+
|
|
137
|
+
async def close(self) -> None:
|
|
138
|
+
if self._owns_client:
|
|
139
|
+
await self._client.aclose()
|
|
140
|
+
|
|
141
|
+
async def __aenter__(self) -> Self:
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
async def __aexit__(self, *_: object) -> None:
|
|
145
|
+
await self.close()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
|
+
from dataclasses import asdict
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .errors import MailEnginError
|
|
6
|
+
from .models import (
|
|
7
|
+
SendBulkEmailRequest,
|
|
8
|
+
SendBulkEmailResponse,
|
|
9
|
+
SendEmailRequest,
|
|
10
|
+
SendEmailResponse,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
Post = Callable[[str, dict[str, Any]], dict[str, Any]]
|
|
14
|
+
AsyncPost = Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _require_content(template_name: str | None, template_id: str | None, html: str | None, subject: str | None) -> None:
|
|
18
|
+
has_template = bool((template_name or "").strip() or (template_id or "").strip())
|
|
19
|
+
has_html = bool((html or "").strip())
|
|
20
|
+
if not has_template and not has_html:
|
|
21
|
+
raise ValueError("Provide template_name (recommended), template_id, or html.")
|
|
22
|
+
if not has_template and not (subject or "").strip():
|
|
23
|
+
raise ValueError("Raw HTML sends require subject.")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _without_none(value: dict[str, Any]) -> dict[str, Any]:
|
|
27
|
+
return {key: item for key, item in value.items() if item is not None}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _send_payload(request: SendEmailRequest) -> dict[str, Any]:
|
|
31
|
+
if not request.to.strip():
|
|
32
|
+
raise ValueError("mailengin.emails.send requires to.")
|
|
33
|
+
_require_content(request.template_name, request.template_id, request.html, request.subject)
|
|
34
|
+
return _without_none(asdict(request))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _bulk_payload(request: SendBulkEmailRequest) -> dict[str, Any]:
|
|
38
|
+
if not request.to:
|
|
39
|
+
raise ValueError("mailengin.emails.send_bulk requires a non-empty to list.")
|
|
40
|
+
if len(request.to) > 1_000:
|
|
41
|
+
raise ValueError("mailengin.emails.send_bulk accepts up to 1000 recipients.")
|
|
42
|
+
recipients: list[str | dict[str, Any]] = []
|
|
43
|
+
for recipient in request.to:
|
|
44
|
+
email = recipient if isinstance(recipient, str) else recipient.email
|
|
45
|
+
if not email.strip():
|
|
46
|
+
raise ValueError("Every bulk recipient must include a non-empty email address.")
|
|
47
|
+
recipients.append(recipient if isinstance(recipient, str) else _without_none(asdict(recipient)))
|
|
48
|
+
_require_content(request.template_name, request.template_id, request.html, request.subject)
|
|
49
|
+
payload = _without_none(asdict(request))
|
|
50
|
+
payload["to"] = recipients
|
|
51
|
+
return payload
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _send_response(data: dict[str, Any]) -> SendEmailResponse:
|
|
55
|
+
try:
|
|
56
|
+
return SendEmailResponse(
|
|
57
|
+
id=str(data["id"]),
|
|
58
|
+
from_email=str(data["from"]),
|
|
59
|
+
to=str(data["to"]),
|
|
60
|
+
template_name=data.get("template_name"),
|
|
61
|
+
created_at=str(data["created_at"]),
|
|
62
|
+
)
|
|
63
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
64
|
+
raise MailEnginError(
|
|
65
|
+
"MailEngin API returned an invalid response.", code="invalid_response", body=data
|
|
66
|
+
) from error
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _bulk_response(data: dict[str, Any]) -> SendBulkEmailResponse:
|
|
70
|
+
try:
|
|
71
|
+
return SendBulkEmailResponse(
|
|
72
|
+
success=bool(data["success"]),
|
|
73
|
+
job_id=str(data["jobId"]),
|
|
74
|
+
queued_count=int(data["queued_count"]),
|
|
75
|
+
sent_count=data.get("sent_count"),
|
|
76
|
+
failed_count=data.get("failed_count"),
|
|
77
|
+
template_name=data.get("template_name"),
|
|
78
|
+
message=str(data["message"]),
|
|
79
|
+
)
|
|
80
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
81
|
+
raise MailEnginError(
|
|
82
|
+
"MailEngin API returned an invalid response.", code="invalid_response", body=data
|
|
83
|
+
) from error
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Emails:
|
|
87
|
+
def __init__(self, post: Post) -> None:
|
|
88
|
+
self._post = post
|
|
89
|
+
|
|
90
|
+
def send(self, request: SendEmailRequest) -> SendEmailResponse:
|
|
91
|
+
return _send_response(self._post("/api/developer/send", _send_payload(request)))
|
|
92
|
+
|
|
93
|
+
def send_bulk(self, request: SendBulkEmailRequest) -> SendBulkEmailResponse:
|
|
94
|
+
return _bulk_response(self._post("/api/developer/send-bulk", _bulk_payload(request)))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class AsyncEmails:
|
|
98
|
+
def __init__(self, post: AsyncPost) -> None:
|
|
99
|
+
self._post = post
|
|
100
|
+
|
|
101
|
+
async def send(self, request: SendEmailRequest) -> SendEmailResponse:
|
|
102
|
+
return _send_response(await self._post("/api/developer/send", _send_payload(request)))
|
|
103
|
+
|
|
104
|
+
async def send_bulk(self, request: SendBulkEmailRequest) -> SendBulkEmailResponse:
|
|
105
|
+
return _bulk_response(await self._post("/api/developer/send-bulk", _bulk_payload(request)))
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MailEnginError(Exception):
|
|
5
|
+
def __init__(
|
|
6
|
+
self,
|
|
7
|
+
message: str,
|
|
8
|
+
*,
|
|
9
|
+
status: int | None = None,
|
|
10
|
+
code: str | None = None,
|
|
11
|
+
request_id: str | None = None,
|
|
12
|
+
retry_after: float | None = None,
|
|
13
|
+
body: Any = None,
|
|
14
|
+
) -> None:
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.status = status
|
|
17
|
+
self.code = code
|
|
18
|
+
self.request_id = request_id
|
|
19
|
+
self.retry_after = retry_after
|
|
20
|
+
self.body = body
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def is_retryable(self) -> bool:
|
|
24
|
+
return self.code in {"network_error", "request_timeout"} or self.status in {
|
|
25
|
+
408,
|
|
26
|
+
429,
|
|
27
|
+
} or (self.status is not None and self.status >= 500)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
Variables = dict[str, Any]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class BulkRecipient:
|
|
9
|
+
email: str
|
|
10
|
+
variables: Variables | None = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
14
|
+
class SendEmailRequest:
|
|
15
|
+
to: str
|
|
16
|
+
template_name: str | None = None
|
|
17
|
+
template_id: str | None = None
|
|
18
|
+
variables: Variables | None = None
|
|
19
|
+
subject: str | None = None
|
|
20
|
+
from_email: str | None = None
|
|
21
|
+
html: str | None = None
|
|
22
|
+
reply_to_mailengin: bool | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class SendEmailResponse:
|
|
27
|
+
id: str
|
|
28
|
+
from_email: str
|
|
29
|
+
to: str
|
|
30
|
+
template_name: str | None
|
|
31
|
+
created_at: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
35
|
+
class SendBulkEmailRequest:
|
|
36
|
+
to: list[str | BulkRecipient]
|
|
37
|
+
template_name: str | None = None
|
|
38
|
+
template_id: str | None = None
|
|
39
|
+
variables: Variables | None = None
|
|
40
|
+
subject: str | None = None
|
|
41
|
+
from_email: str | None = None
|
|
42
|
+
html: str | None = None
|
|
43
|
+
reply_to_mailengin: bool | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class SendBulkEmailResponse:
|
|
48
|
+
success: bool
|
|
49
|
+
job_id: str
|
|
50
|
+
queued_count: int
|
|
51
|
+
template_name: str | None
|
|
52
|
+
message: str
|
|
53
|
+
sent_count: int | None = field(default=None)
|
|
54
|
+
failed_count: int | None = field(default=None)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from mailengin import (
|
|
7
|
+
AsyncMailEngin,
|
|
8
|
+
BulkRecipient,
|
|
9
|
+
MailEngin,
|
|
10
|
+
MailEnginError,
|
|
11
|
+
SendBulkEmailRequest,
|
|
12
|
+
SendEmailRequest,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_send_maps_request_and_response() -> None:
|
|
17
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
18
|
+
assert request.url == "https://api.mailengin.app/api/developer/send"
|
|
19
|
+
assert request.headers["authorization"] == "Bearer re_test_key"
|
|
20
|
+
assert request.headers["user-agent"] == "mailengin-python/0.1.0"
|
|
21
|
+
assert json.loads(request.content) == {
|
|
22
|
+
"to": "person@example.com",
|
|
23
|
+
"template_name": "welcome-email",
|
|
24
|
+
"variables": {"first_name": "Asha"},
|
|
25
|
+
"from_email": "hello@example.com",
|
|
26
|
+
"reply_to_mailengin": True,
|
|
27
|
+
}
|
|
28
|
+
return httpx.Response(200, json={
|
|
29
|
+
"id": "msg_123", "from": "hello@example.com", "to": "person@example.com",
|
|
30
|
+
"template_name": "welcome-email", "created_at": "2026-08-18T10:00:00Z",
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
client = MailEngin("re_test_key", http_client=httpx.Client(transport=httpx.MockTransport(handler)))
|
|
34
|
+
result = client.emails.send(SendEmailRequest(
|
|
35
|
+
to="person@example.com", template_name="welcome-email",
|
|
36
|
+
variables={"first_name": "Asha"}, from_email="hello@example.com",
|
|
37
|
+
reply_to_mailengin=True,
|
|
38
|
+
))
|
|
39
|
+
assert result.id == "msg_123"
|
|
40
|
+
assert result.template_name == "welcome-email"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_bulk_and_rate_limit_error() -> None:
|
|
44
|
+
calls = 0
|
|
45
|
+
|
|
46
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
47
|
+
nonlocal calls
|
|
48
|
+
calls += 1
|
|
49
|
+
payload = json.loads(request.content)
|
|
50
|
+
assert payload["to"][0] == {"email": "a@example.com", "variables": {"name": "A"}}
|
|
51
|
+
return httpx.Response(429, headers={"Retry-After": "12", "x-request-id": "req_1"}, json={
|
|
52
|
+
"message": "Rate limit exceeded", "code": "rate_limited",
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
client = MailEngin("re_test_key", http_client=httpx.Client(transport=httpx.MockTransport(handler)))
|
|
56
|
+
with pytest.raises(MailEnginError) as caught:
|
|
57
|
+
client.emails.send_bulk(SendBulkEmailRequest(
|
|
58
|
+
to=[BulkRecipient("a@example.com", {"name": "A"})], template_name="welcome",
|
|
59
|
+
))
|
|
60
|
+
assert calls == 1
|
|
61
|
+
assert caught.value.status == 429
|
|
62
|
+
assert caught.value.retry_after == 12
|
|
63
|
+
assert caught.value.request_id == "req_1"
|
|
64
|
+
assert caught.value.is_retryable
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_validates_before_request() -> None:
|
|
68
|
+
called = False
|
|
69
|
+
|
|
70
|
+
def handler(_: httpx.Request) -> httpx.Response:
|
|
71
|
+
nonlocal called
|
|
72
|
+
called = True
|
|
73
|
+
return httpx.Response(200, json={})
|
|
74
|
+
|
|
75
|
+
client = MailEngin("re_test_key", http_client=httpx.Client(transport=httpx.MockTransport(handler)))
|
|
76
|
+
with pytest.raises(ValueError, match="Raw HTML"):
|
|
77
|
+
client.emails.send(SendEmailRequest(to="person@example.com", html="<p>Hello</p>"))
|
|
78
|
+
assert not called
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_bulk_success_and_recipient_limit() -> None:
|
|
82
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
83
|
+
payload = json.loads(request.content)
|
|
84
|
+
assert payload["to"] == ["a@example.com", {"email": "b@example.com", "variables": {"name": "B"}}]
|
|
85
|
+
return httpx.Response(200, json={
|
|
86
|
+
"success": True, "jobId": "bulk_1", "queued_count": 2,
|
|
87
|
+
"template_name": "welcome", "message": "Queued",
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
client = MailEngin("re_test_key", http_client=httpx.Client(transport=httpx.MockTransport(handler)))
|
|
91
|
+
result = client.emails.send_bulk(SendBulkEmailRequest(
|
|
92
|
+
to=["a@example.com", BulkRecipient("b@example.com", {"name": "B"})],
|
|
93
|
+
template_name="welcome",
|
|
94
|
+
))
|
|
95
|
+
assert result.job_id == "bulk_1"
|
|
96
|
+
with pytest.raises(ValueError, match="1000"):
|
|
97
|
+
client.emails.send_bulk(SendBulkEmailRequest(
|
|
98
|
+
to=["a@example.com"] * 1_001, template_name="welcome",
|
|
99
|
+
))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_invalid_response_and_network_failure() -> None:
|
|
103
|
+
invalid = MailEngin("re_test_key", http_client=httpx.Client(
|
|
104
|
+
transport=httpx.MockTransport(lambda _: httpx.Response(200, json={})),
|
|
105
|
+
))
|
|
106
|
+
with pytest.raises(MailEnginError) as malformed:
|
|
107
|
+
invalid.emails.send(SendEmailRequest(to="person@example.com", template_name="welcome"))
|
|
108
|
+
assert malformed.value.code == "invalid_response"
|
|
109
|
+
|
|
110
|
+
def fail(request: httpx.Request) -> httpx.Response:
|
|
111
|
+
raise httpx.ConnectError("offline", request=request)
|
|
112
|
+
|
|
113
|
+
offline = MailEngin("re_test_key", http_client=httpx.Client(transport=httpx.MockTransport(fail)))
|
|
114
|
+
with pytest.raises(MailEnginError) as network:
|
|
115
|
+
offline.emails.send(SendEmailRequest(to="person@example.com", template_name="welcome"))
|
|
116
|
+
assert network.value.code == "network_error"
|
|
117
|
+
assert network.value.is_retryable
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.mark.asyncio
|
|
121
|
+
async def test_async_client() -> None:
|
|
122
|
+
async def handler(_: httpx.Request) -> httpx.Response:
|
|
123
|
+
return httpx.Response(200, json={
|
|
124
|
+
"id": "msg_async", "from": "hello@example.com", "to": "person@example.com",
|
|
125
|
+
"template_name": None, "created_at": "2026-08-18T10:00:00Z",
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
transport = httpx.MockTransport(handler)
|
|
129
|
+
http_client = httpx.AsyncClient(transport=transport)
|
|
130
|
+
client = AsyncMailEngin("re_test_key", http_client=http_client)
|
|
131
|
+
result = await client.emails.send(SendEmailRequest(
|
|
132
|
+
to="person@example.com", subject="Hello", html="<p>Hello</p>",
|
|
133
|
+
))
|
|
134
|
+
assert result.id == "msg_async"
|
|
135
|
+
await http_client.aclose()
|