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.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ .pytest_cache/
3
+ .mypy_cache/
4
+ .ruff_cache/
5
+ .venv/
6
+ dist/
7
+ build/
8
+ *.egg-info/
@@ -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.
@@ -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.
@@ -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
+ [![Python](https://img.shields.io/badge/Python-3.10%2B-3776ab.svg)](https://www.python.org/)
36
+ [![Typing](https://img.shields.io/badge/typing-strict-2563eb.svg)](https://mypy.readthedocs.io/)
37
+ [![License: MIT](https://img.shields.io/badge/License-MIT-111827.svg)](./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
+ [![Python](https://img.shields.io/badge/Python-3.10%2B-3776ab.svg)](https://www.python.org/)
4
+ [![Typing](https://img.shields.io/badge/typing-strict-2563eb.svg)](https://mypy.readthedocs.io/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-111827.svg)](./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()