zapsmtp 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.
- zapsmtp-0.1.0/.gitignore +12 -0
- zapsmtp-0.1.0/LICENSE +21 -0
- zapsmtp-0.1.0/PKG-INFO +332 -0
- zapsmtp-0.1.0/README.md +301 -0
- zapsmtp-0.1.0/pyproject.toml +60 -0
- zapsmtp-0.1.0/src/zapsmtp/__init__.py +61 -0
- zapsmtp-0.1.0/src/zapsmtp/_attachments.py +65 -0
- zapsmtp-0.1.0/src/zapsmtp/_base_client.py +501 -0
- zapsmtp-0.1.0/src/zapsmtp/_exceptions.py +148 -0
- zapsmtp-0.1.0/src/zapsmtp/_generated/__init__.py +2 -0
- zapsmtp-0.1.0/src/zapsmtp/_generated/async_resources.py +4661 -0
- zapsmtp-0.1.0/src/zapsmtp/_generated/client.py +206 -0
- zapsmtp-0.1.0/src/zapsmtp/_generated/models.py +2243 -0
- zapsmtp-0.1.0/src/zapsmtp/_generated/params.py +155 -0
- zapsmtp-0.1.0/src/zapsmtp/_generated/resources.py +4661 -0
- zapsmtp-0.1.0/src/zapsmtp/_models.py +114 -0
- zapsmtp-0.1.0/src/zapsmtp/_pagination.py +195 -0
- zapsmtp-0.1.0/src/zapsmtp/_resource.py +24 -0
- zapsmtp-0.1.0/src/zapsmtp/_response.py +97 -0
- zapsmtp-0.1.0/src/zapsmtp/_retry.py +65 -0
- zapsmtp-0.1.0/src/zapsmtp/_types.py +40 -0
- zapsmtp-0.1.0/src/zapsmtp/_utils.py +129 -0
- zapsmtp-0.1.0/src/zapsmtp/_version.py +2 -0
- zapsmtp-0.1.0/src/zapsmtp/_webhooks.py +153 -0
- zapsmtp-0.1.0/src/zapsmtp/py.typed +0 -0
- zapsmtp-0.1.0/src/zapsmtp/types.py +11 -0
zapsmtp-0.1.0/.gitignore
ADDED
zapsmtp-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ZapSMTP
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
zapsmtp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: zapsmtp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The official Python library for the ZapSMTP email API
|
|
5
|
+
Project-URL: Homepage, https://zapsmtp.com
|
|
6
|
+
Project-URL: Documentation, https://zapsmtp.com/docs/sdks/
|
|
7
|
+
Author: ZapSMTP
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: api,email,sdk,smtp,transactional email,zapsmtp
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Communications :: Email
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Requires-Dist: httpx<1,>=0.23.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-cov>=4; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# ZapSMTP Python SDK
|
|
33
|
+
|
|
34
|
+
The official Python library for the [ZapSMTP](https://zapsmtp.com) email API: send, receive and track
|
|
35
|
+
email, manage templates, domains, webhooks and everything else the API does.
|
|
36
|
+
|
|
37
|
+
- Every API operation, with types for every parameter and response (`mypy --strict` clean, `py.typed`)
|
|
38
|
+
- A synchronous client, `ZapSMTP`, and an asyncio client, `AsyncZapSMTP`
|
|
39
|
+
- Safe automatic retries: sends carry an `Idempotency-Key`, so a retry never sends twice
|
|
40
|
+
- `for` (or `async for`) over every page of any list
|
|
41
|
+
- Webhook signature verification
|
|
42
|
+
- Python 3.9+; one dependency, [httpx](https://www.python-httpx.org)
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install zapsmtp
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Send an email
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from zapsmtp import ZapSMTP
|
|
54
|
+
|
|
55
|
+
client = ZapSMTP("zap_...") # or set ZAP_API_KEY and call ZapSMTP()
|
|
56
|
+
|
|
57
|
+
email = client.emails.send(
|
|
58
|
+
from_="Acme <hello@acme.com>",
|
|
59
|
+
to="jane@example.org",
|
|
60
|
+
subject="Your receipt",
|
|
61
|
+
html="<p>Thanks for your order.</p>",
|
|
62
|
+
tags=[{"name": "category", "value": "receipt"}],
|
|
63
|
+
metadata={"order_id": "A-1042"},
|
|
64
|
+
)
|
|
65
|
+
print(email.id)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`from` is a Python keyword, so the parameter is `from_`; it is sent as `"from"`. The same goes for any
|
|
69
|
+
other parameter or response field named after a keyword (`template.from_`).
|
|
70
|
+
|
|
71
|
+
With a template:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
client.emails.send(
|
|
75
|
+
from_="Acme <hello@acme.com>",
|
|
76
|
+
to="jane@example.org",
|
|
77
|
+
template={"id": "receipt", "variables": {"name": "Jane", "total": 42}},
|
|
78
|
+
)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Attachments:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from zapsmtp import attachment, attachment_from_file
|
|
85
|
+
|
|
86
|
+
client.emails.send(
|
|
87
|
+
from_="Acme <hello@acme.com>",
|
|
88
|
+
to="jane@example.org",
|
|
89
|
+
subject="Your invoice",
|
|
90
|
+
text="Attached.",
|
|
91
|
+
attachments=[
|
|
92
|
+
attachment_from_file("invoice.pdf"), # content type from the extension
|
|
93
|
+
attachment("notes.txt", "Thank you!"), # bytes or text
|
|
94
|
+
attachment("logo.png", png_bytes, content_id="logo"), # inline: <img src="cid:logo">
|
|
95
|
+
],
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Up to 100 emails at once. Batch items are plain dicts (`zapsmtp.types.SendEmailParams`) and use the
|
|
100
|
+
API's own key `"from"`:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
result = client.emails.send_batch(
|
|
104
|
+
[
|
|
105
|
+
{"from": "Acme <hello@acme.com>", "to": "jane@example.org", "subject": "Hi Jane", "text": "..."},
|
|
106
|
+
{"from": "Acme <hello@acme.com>", "to": "joe@example.org", "subject": "Hi Joe", "text": "..."},
|
|
107
|
+
],
|
|
108
|
+
validation="permissive", # send the valid ones, report the others in result.errors
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Images for templates:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from pathlib import Path
|
|
116
|
+
|
|
117
|
+
asset = client.template_assets.upload_file(Path("logo.png"), name="Logo")
|
|
118
|
+
print(asset.url)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Async
|
|
122
|
+
|
|
123
|
+
`AsyncZapSMTP` has the same resources and methods; await them.
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
import asyncio
|
|
127
|
+
from zapsmtp import AsyncZapSMTP
|
|
128
|
+
|
|
129
|
+
async def main() -> None:
|
|
130
|
+
async with AsyncZapSMTP() as client:
|
|
131
|
+
email = await client.emails.send(from_="hello@acme.com", to="jane@example.org", subject="Hi", text="Hello")
|
|
132
|
+
print(email.id)
|
|
133
|
+
|
|
134
|
+
asyncio.run(main())
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Responses
|
|
138
|
+
|
|
139
|
+
Results are typed models: `email.subject`, `domain.dkim_verified`. A field the API left out is `None`, and a
|
|
140
|
+
field this version of the SDK does not know yet is kept: read it with `email["new_field"]`.
|
|
141
|
+
`to_dict()` gives the JSON exactly as the API sent it (a number JSON cannot hold, such as `1e999`,
|
|
142
|
+
reads as `None`). Models are read-only.
|
|
143
|
+
|
|
144
|
+
## Lists
|
|
145
|
+
|
|
146
|
+
A list call returns its first page: `page.data`, `page.has_more`, `page.next_cursor`,
|
|
147
|
+
`page.has_next_page()` and `page.get_next_page()`. Iterating the page walks every item of every page,
|
|
148
|
+
asking the API as it goes, and keeps your filters.
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
page = client.emails.list(limit=50, status="bounced")
|
|
152
|
+
print(len(page.data), page.has_more)
|
|
153
|
+
|
|
154
|
+
for email in client.emails.list(status="bounced"):
|
|
155
|
+
print(email.id, email.to)
|
|
156
|
+
|
|
157
|
+
for page in client.emails.list(limit=100).iter_pages():
|
|
158
|
+
...
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
With the async client, `await` gives the first page and `async for` walks them all:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
first = await client.emails.list(limit=50)
|
|
165
|
+
async for email in client.emails.list(status="bounced"):
|
|
166
|
+
print(email.id)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Errors
|
|
170
|
+
|
|
171
|
+
Every API error raises a `ZapSMTPError` subclass with `status`, `code`, `message`, `type`,
|
|
172
|
+
`request_id`, `headers` and `body`.
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
import zapsmtp
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
client.emails.send(from_="hello@acme.com", to="jane@example.org", subject="Hi", text="Hello")
|
|
179
|
+
except zapsmtp.ValidationError as err:
|
|
180
|
+
print(err.code, err.message) # e.g. invalid_parameter
|
|
181
|
+
except zapsmtp.RateLimitError as err:
|
|
182
|
+
print("retry in", err.retry_after, "s")
|
|
183
|
+
except zapsmtp.ZapSMTPError as err:
|
|
184
|
+
print(err.status, err.code, err.request_id)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
| Class | When |
|
|
188
|
+
|---|---|
|
|
189
|
+
| `AuthenticationError` | 401: missing or invalid key |
|
|
190
|
+
| `PermissionError` | 403: the key may not do this, or the domain is not verified |
|
|
191
|
+
| `NotFoundError` | 404 |
|
|
192
|
+
| `ConflictError` | 409 |
|
|
193
|
+
| `ValidationError` | 400, 413, 422: the request is wrong |
|
|
194
|
+
| `RateLimitError` | 429: `retry_after` says how many seconds to wait |
|
|
195
|
+
| `ServerError` | 5xx |
|
|
196
|
+
| `ConnectionError` | no answer: network failure or timeout (`status` is 0) |
|
|
197
|
+
|
|
198
|
+
`zapsmtp.PermissionError` and `zapsmtp.ConnectionError` are the SDK's own classes, not Python's
|
|
199
|
+
built-ins of the same name: refer to them through the module.
|
|
200
|
+
|
|
201
|
+
## Retries and timeouts
|
|
202
|
+
|
|
203
|
+
A request that failed on the way (408, 429, 5xx, a lost connection, a timeout) is tried again twice by
|
|
204
|
+
default, waiting as the API's `Retry-After` says (up to 60 s) or backing off exponentially (0.5 s, 1 s,
|
|
205
|
+
2 s, ... up to 8 s, with jitter). Only requests that are safe to repeat are retried: reads, `PUT`s,
|
|
206
|
+
deletes, and sends, which always carry an `Idempotency-Key`. Other `POST` and `PATCH` requests are
|
|
207
|
+
retried only on 429. Quota errors (`monthly_quota_exceeded`, `daily_quota_exceeded`) are not retried.
|
|
208
|
+
|
|
209
|
+
Pass your own key to make retries of your own safe too:
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
client.emails.send(..., idempotency_key=f"receipt-{order.id}")
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
An empty or blank key (as `idempotency_key=` or an `Idempotency-Key` header) raises `ValidationError`
|
|
216
|
+
before anything is sent: the API does not deduplicate on an empty key.
|
|
217
|
+
|
|
218
|
+
The timeout covers one attempt, reading the response included: 60 s by default.
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
client = ZapSMTP(max_retries=4, timeout=20)
|
|
222
|
+
client.emails.get(email_id, timeout=5, max_retries=0, extra_headers={"X-Trace": "1"})
|
|
223
|
+
fast = client.with_options(timeout=5) # a copy sharing the connection pool
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Response headers
|
|
227
|
+
|
|
228
|
+
Every resource has a `with_response` view whose methods return an `APIResponse` with `data`, `status`,
|
|
229
|
+
`headers` and `request_id`:
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
response = client.emails.with_response.send(from_="hello@acme.com", to="jane@example.org", subject="Hi", text="Hello")
|
|
233
|
+
print(response.data.id, response.request_id, response.headers.get("ratelimit-remaining"))
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Webhooks
|
|
237
|
+
|
|
238
|
+
Check the signature on the raw body, before parsing it. `verify_webhook` returns the event, or raises
|
|
239
|
+
`WebhookVerificationError` when the signature is missing, wrong, or older than five minutes.
|
|
240
|
+
|
|
241
|
+
Flask:
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
import os
|
|
245
|
+
from flask import Flask, request
|
|
246
|
+
from zapsmtp import WebhookVerificationError, verify_webhook
|
|
247
|
+
|
|
248
|
+
app = Flask(__name__)
|
|
249
|
+
|
|
250
|
+
@app.post("/webhooks/zapsmtp")
|
|
251
|
+
def zapsmtp_webhook():
|
|
252
|
+
try:
|
|
253
|
+
event = verify_webhook(request.get_data(), request.headers, os.environ["ZAP_WEBHOOK_SECRET"])
|
|
254
|
+
except WebhookVerificationError:
|
|
255
|
+
return "", 400
|
|
256
|
+
if event.type == "email.bounced":
|
|
257
|
+
...
|
|
258
|
+
return "", 200
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
FastAPI:
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
265
|
+
|
|
266
|
+
app = FastAPI()
|
|
267
|
+
|
|
268
|
+
@app.post("/webhooks/zapsmtp")
|
|
269
|
+
async def zapsmtp_webhook(request: Request) -> None:
|
|
270
|
+
try:
|
|
271
|
+
event = verify_webhook(await request.body(), request.headers, os.environ["ZAP_WEBHOOK_SECRET"])
|
|
272
|
+
except WebhookVerificationError:
|
|
273
|
+
raise HTTPException(status_code=400)
|
|
274
|
+
print(event.type, event.data)
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Django:
|
|
278
|
+
|
|
279
|
+
```python
|
|
280
|
+
from django.http import HttpResponse, HttpResponseBadRequest
|
|
281
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
282
|
+
from django.views.decorators.http import require_POST
|
|
283
|
+
|
|
284
|
+
@csrf_exempt
|
|
285
|
+
@require_POST
|
|
286
|
+
def zapsmtp_webhook(request):
|
|
287
|
+
try:
|
|
288
|
+
event = verify_webhook(request.body, request.headers, os.environ["ZAP_WEBHOOK_SECRET"])
|
|
289
|
+
except WebhookVerificationError:
|
|
290
|
+
return HttpResponseBadRequest()
|
|
291
|
+
...
|
|
292
|
+
return HttpResponse()
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
A webhook with batch delivery (`batch: true`) signs a JSON array of events. `verify_webhook` refuses
|
|
296
|
+
it; use `verify_webhook_events`, which returns a list: every event of a batch, or the one event of any
|
|
297
|
+
other request.
|
|
298
|
+
|
|
299
|
+
```python
|
|
300
|
+
from zapsmtp import verify_webhook_events
|
|
301
|
+
|
|
302
|
+
for event in verify_webhook_events(request.get_data(), request.headers, os.environ["ZAP_WEBHOOK_SECRET"]):
|
|
303
|
+
handle(event)
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
`headers` may be any mapping of request headers, a list of `(name, value)` pairs, or the
|
|
307
|
+
`Zap-Signature` value itself. `client.webhooks.verify(...)` and `client.webhooks.verify_events(...)` do the same. Pass `tolerance=` (seconds)
|
|
308
|
+
to change the five minutes.
|
|
309
|
+
|
|
310
|
+
## Configuration
|
|
311
|
+
|
|
312
|
+
| Argument | Environment | Default |
|
|
313
|
+
|---|---|---|
|
|
314
|
+
| `api_key` | `ZAP_API_KEY` | required |
|
|
315
|
+
| `base_url` | `ZAP_BASE_URL` | `https://api.zapsmtp.com/v1` |
|
|
316
|
+
| `timeout` | | 60 s per attempt (`None`: no limit) |
|
|
317
|
+
| `max_retries` | | 2 |
|
|
318
|
+
| `default_headers` | | |
|
|
319
|
+
| `http_client` | | an `httpx.Client` (`httpx.AsyncClient`) of its own |
|
|
320
|
+
|
|
321
|
+
Pass your own `http_client` for proxies, custom TLS or transports; the SDK does not close a client it
|
|
322
|
+
was given. Close the SDK's own with `client.close()` (`await client.close()`), or use the client as a
|
|
323
|
+
context manager.
|
|
324
|
+
|
|
325
|
+
## Development
|
|
326
|
+
|
|
327
|
+
```bash
|
|
328
|
+
python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'
|
|
329
|
+
.venv/bin/pytest # conformance (starts sdks/conformance/server.mjs with node), core, webhooks; 100% coverage
|
|
330
|
+
.venv/bin/mypy # --strict
|
|
331
|
+
node ../codegen/generate.mjs --only python # regenerate src/zapsmtp/_generated after an API change
|
|
332
|
+
```
|
zapsmtp-0.1.0/README.md
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# ZapSMTP Python SDK
|
|
2
|
+
|
|
3
|
+
The official Python library for the [ZapSMTP](https://zapsmtp.com) email API: send, receive and track
|
|
4
|
+
email, manage templates, domains, webhooks and everything else the API does.
|
|
5
|
+
|
|
6
|
+
- Every API operation, with types for every parameter and response (`mypy --strict` clean, `py.typed`)
|
|
7
|
+
- A synchronous client, `ZapSMTP`, and an asyncio client, `AsyncZapSMTP`
|
|
8
|
+
- Safe automatic retries: sends carry an `Idempotency-Key`, so a retry never sends twice
|
|
9
|
+
- `for` (or `async for`) over every page of any list
|
|
10
|
+
- Webhook signature verification
|
|
11
|
+
- Python 3.9+; one dependency, [httpx](https://www.python-httpx.org)
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install zapsmtp
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Send an email
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from zapsmtp import ZapSMTP
|
|
23
|
+
|
|
24
|
+
client = ZapSMTP("zap_...") # or set ZAP_API_KEY and call ZapSMTP()
|
|
25
|
+
|
|
26
|
+
email = client.emails.send(
|
|
27
|
+
from_="Acme <hello@acme.com>",
|
|
28
|
+
to="jane@example.org",
|
|
29
|
+
subject="Your receipt",
|
|
30
|
+
html="<p>Thanks for your order.</p>",
|
|
31
|
+
tags=[{"name": "category", "value": "receipt"}],
|
|
32
|
+
metadata={"order_id": "A-1042"},
|
|
33
|
+
)
|
|
34
|
+
print(email.id)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`from` is a Python keyword, so the parameter is `from_`; it is sent as `"from"`. The same goes for any
|
|
38
|
+
other parameter or response field named after a keyword (`template.from_`).
|
|
39
|
+
|
|
40
|
+
With a template:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
client.emails.send(
|
|
44
|
+
from_="Acme <hello@acme.com>",
|
|
45
|
+
to="jane@example.org",
|
|
46
|
+
template={"id": "receipt", "variables": {"name": "Jane", "total": 42}},
|
|
47
|
+
)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Attachments:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from zapsmtp import attachment, attachment_from_file
|
|
54
|
+
|
|
55
|
+
client.emails.send(
|
|
56
|
+
from_="Acme <hello@acme.com>",
|
|
57
|
+
to="jane@example.org",
|
|
58
|
+
subject="Your invoice",
|
|
59
|
+
text="Attached.",
|
|
60
|
+
attachments=[
|
|
61
|
+
attachment_from_file("invoice.pdf"), # content type from the extension
|
|
62
|
+
attachment("notes.txt", "Thank you!"), # bytes or text
|
|
63
|
+
attachment("logo.png", png_bytes, content_id="logo"), # inline: <img src="cid:logo">
|
|
64
|
+
],
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Up to 100 emails at once. Batch items are plain dicts (`zapsmtp.types.SendEmailParams`) and use the
|
|
69
|
+
API's own key `"from"`:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
result = client.emails.send_batch(
|
|
73
|
+
[
|
|
74
|
+
{"from": "Acme <hello@acme.com>", "to": "jane@example.org", "subject": "Hi Jane", "text": "..."},
|
|
75
|
+
{"from": "Acme <hello@acme.com>", "to": "joe@example.org", "subject": "Hi Joe", "text": "..."},
|
|
76
|
+
],
|
|
77
|
+
validation="permissive", # send the valid ones, report the others in result.errors
|
|
78
|
+
)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Images for templates:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from pathlib import Path
|
|
85
|
+
|
|
86
|
+
asset = client.template_assets.upload_file(Path("logo.png"), name="Logo")
|
|
87
|
+
print(asset.url)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Async
|
|
91
|
+
|
|
92
|
+
`AsyncZapSMTP` has the same resources and methods; await them.
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
import asyncio
|
|
96
|
+
from zapsmtp import AsyncZapSMTP
|
|
97
|
+
|
|
98
|
+
async def main() -> None:
|
|
99
|
+
async with AsyncZapSMTP() as client:
|
|
100
|
+
email = await client.emails.send(from_="hello@acme.com", to="jane@example.org", subject="Hi", text="Hello")
|
|
101
|
+
print(email.id)
|
|
102
|
+
|
|
103
|
+
asyncio.run(main())
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Responses
|
|
107
|
+
|
|
108
|
+
Results are typed models: `email.subject`, `domain.dkim_verified`. A field the API left out is `None`, and a
|
|
109
|
+
field this version of the SDK does not know yet is kept: read it with `email["new_field"]`.
|
|
110
|
+
`to_dict()` gives the JSON exactly as the API sent it (a number JSON cannot hold, such as `1e999`,
|
|
111
|
+
reads as `None`). Models are read-only.
|
|
112
|
+
|
|
113
|
+
## Lists
|
|
114
|
+
|
|
115
|
+
A list call returns its first page: `page.data`, `page.has_more`, `page.next_cursor`,
|
|
116
|
+
`page.has_next_page()` and `page.get_next_page()`. Iterating the page walks every item of every page,
|
|
117
|
+
asking the API as it goes, and keeps your filters.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
page = client.emails.list(limit=50, status="bounced")
|
|
121
|
+
print(len(page.data), page.has_more)
|
|
122
|
+
|
|
123
|
+
for email in client.emails.list(status="bounced"):
|
|
124
|
+
print(email.id, email.to)
|
|
125
|
+
|
|
126
|
+
for page in client.emails.list(limit=100).iter_pages():
|
|
127
|
+
...
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
With the async client, `await` gives the first page and `async for` walks them all:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
first = await client.emails.list(limit=50)
|
|
134
|
+
async for email in client.emails.list(status="bounced"):
|
|
135
|
+
print(email.id)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Errors
|
|
139
|
+
|
|
140
|
+
Every API error raises a `ZapSMTPError` subclass with `status`, `code`, `message`, `type`,
|
|
141
|
+
`request_id`, `headers` and `body`.
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
import zapsmtp
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
client.emails.send(from_="hello@acme.com", to="jane@example.org", subject="Hi", text="Hello")
|
|
148
|
+
except zapsmtp.ValidationError as err:
|
|
149
|
+
print(err.code, err.message) # e.g. invalid_parameter
|
|
150
|
+
except zapsmtp.RateLimitError as err:
|
|
151
|
+
print("retry in", err.retry_after, "s")
|
|
152
|
+
except zapsmtp.ZapSMTPError as err:
|
|
153
|
+
print(err.status, err.code, err.request_id)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
| Class | When |
|
|
157
|
+
|---|---|
|
|
158
|
+
| `AuthenticationError` | 401: missing or invalid key |
|
|
159
|
+
| `PermissionError` | 403: the key may not do this, or the domain is not verified |
|
|
160
|
+
| `NotFoundError` | 404 |
|
|
161
|
+
| `ConflictError` | 409 |
|
|
162
|
+
| `ValidationError` | 400, 413, 422: the request is wrong |
|
|
163
|
+
| `RateLimitError` | 429: `retry_after` says how many seconds to wait |
|
|
164
|
+
| `ServerError` | 5xx |
|
|
165
|
+
| `ConnectionError` | no answer: network failure or timeout (`status` is 0) |
|
|
166
|
+
|
|
167
|
+
`zapsmtp.PermissionError` and `zapsmtp.ConnectionError` are the SDK's own classes, not Python's
|
|
168
|
+
built-ins of the same name: refer to them through the module.
|
|
169
|
+
|
|
170
|
+
## Retries and timeouts
|
|
171
|
+
|
|
172
|
+
A request that failed on the way (408, 429, 5xx, a lost connection, a timeout) is tried again twice by
|
|
173
|
+
default, waiting as the API's `Retry-After` says (up to 60 s) or backing off exponentially (0.5 s, 1 s,
|
|
174
|
+
2 s, ... up to 8 s, with jitter). Only requests that are safe to repeat are retried: reads, `PUT`s,
|
|
175
|
+
deletes, and sends, which always carry an `Idempotency-Key`. Other `POST` and `PATCH` requests are
|
|
176
|
+
retried only on 429. Quota errors (`monthly_quota_exceeded`, `daily_quota_exceeded`) are not retried.
|
|
177
|
+
|
|
178
|
+
Pass your own key to make retries of your own safe too:
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
client.emails.send(..., idempotency_key=f"receipt-{order.id}")
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
An empty or blank key (as `idempotency_key=` or an `Idempotency-Key` header) raises `ValidationError`
|
|
185
|
+
before anything is sent: the API does not deduplicate on an empty key.
|
|
186
|
+
|
|
187
|
+
The timeout covers one attempt, reading the response included: 60 s by default.
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
client = ZapSMTP(max_retries=4, timeout=20)
|
|
191
|
+
client.emails.get(email_id, timeout=5, max_retries=0, extra_headers={"X-Trace": "1"})
|
|
192
|
+
fast = client.with_options(timeout=5) # a copy sharing the connection pool
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Response headers
|
|
196
|
+
|
|
197
|
+
Every resource has a `with_response` view whose methods return an `APIResponse` with `data`, `status`,
|
|
198
|
+
`headers` and `request_id`:
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
response = client.emails.with_response.send(from_="hello@acme.com", to="jane@example.org", subject="Hi", text="Hello")
|
|
202
|
+
print(response.data.id, response.request_id, response.headers.get("ratelimit-remaining"))
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Webhooks
|
|
206
|
+
|
|
207
|
+
Check the signature on the raw body, before parsing it. `verify_webhook` returns the event, or raises
|
|
208
|
+
`WebhookVerificationError` when the signature is missing, wrong, or older than five minutes.
|
|
209
|
+
|
|
210
|
+
Flask:
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
import os
|
|
214
|
+
from flask import Flask, request
|
|
215
|
+
from zapsmtp import WebhookVerificationError, verify_webhook
|
|
216
|
+
|
|
217
|
+
app = Flask(__name__)
|
|
218
|
+
|
|
219
|
+
@app.post("/webhooks/zapsmtp")
|
|
220
|
+
def zapsmtp_webhook():
|
|
221
|
+
try:
|
|
222
|
+
event = verify_webhook(request.get_data(), request.headers, os.environ["ZAP_WEBHOOK_SECRET"])
|
|
223
|
+
except WebhookVerificationError:
|
|
224
|
+
return "", 400
|
|
225
|
+
if event.type == "email.bounced":
|
|
226
|
+
...
|
|
227
|
+
return "", 200
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
FastAPI:
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
234
|
+
|
|
235
|
+
app = FastAPI()
|
|
236
|
+
|
|
237
|
+
@app.post("/webhooks/zapsmtp")
|
|
238
|
+
async def zapsmtp_webhook(request: Request) -> None:
|
|
239
|
+
try:
|
|
240
|
+
event = verify_webhook(await request.body(), request.headers, os.environ["ZAP_WEBHOOK_SECRET"])
|
|
241
|
+
except WebhookVerificationError:
|
|
242
|
+
raise HTTPException(status_code=400)
|
|
243
|
+
print(event.type, event.data)
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Django:
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
from django.http import HttpResponse, HttpResponseBadRequest
|
|
250
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
251
|
+
from django.views.decorators.http import require_POST
|
|
252
|
+
|
|
253
|
+
@csrf_exempt
|
|
254
|
+
@require_POST
|
|
255
|
+
def zapsmtp_webhook(request):
|
|
256
|
+
try:
|
|
257
|
+
event = verify_webhook(request.body, request.headers, os.environ["ZAP_WEBHOOK_SECRET"])
|
|
258
|
+
except WebhookVerificationError:
|
|
259
|
+
return HttpResponseBadRequest()
|
|
260
|
+
...
|
|
261
|
+
return HttpResponse()
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
A webhook with batch delivery (`batch: true`) signs a JSON array of events. `verify_webhook` refuses
|
|
265
|
+
it; use `verify_webhook_events`, which returns a list: every event of a batch, or the one event of any
|
|
266
|
+
other request.
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
from zapsmtp import verify_webhook_events
|
|
270
|
+
|
|
271
|
+
for event in verify_webhook_events(request.get_data(), request.headers, os.environ["ZAP_WEBHOOK_SECRET"]):
|
|
272
|
+
handle(event)
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
`headers` may be any mapping of request headers, a list of `(name, value)` pairs, or the
|
|
276
|
+
`Zap-Signature` value itself. `client.webhooks.verify(...)` and `client.webhooks.verify_events(...)` do the same. Pass `tolerance=` (seconds)
|
|
277
|
+
to change the five minutes.
|
|
278
|
+
|
|
279
|
+
## Configuration
|
|
280
|
+
|
|
281
|
+
| Argument | Environment | Default |
|
|
282
|
+
|---|---|---|
|
|
283
|
+
| `api_key` | `ZAP_API_KEY` | required |
|
|
284
|
+
| `base_url` | `ZAP_BASE_URL` | `https://api.zapsmtp.com/v1` |
|
|
285
|
+
| `timeout` | | 60 s per attempt (`None`: no limit) |
|
|
286
|
+
| `max_retries` | | 2 |
|
|
287
|
+
| `default_headers` | | |
|
|
288
|
+
| `http_client` | | an `httpx.Client` (`httpx.AsyncClient`) of its own |
|
|
289
|
+
|
|
290
|
+
Pass your own `http_client` for proxies, custom TLS or transports; the SDK does not close a client it
|
|
291
|
+
was given. Close the SDK's own with `client.close()` (`await client.close()`), or use the client as a
|
|
292
|
+
context manager.
|
|
293
|
+
|
|
294
|
+
## Development
|
|
295
|
+
|
|
296
|
+
```bash
|
|
297
|
+
python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'
|
|
298
|
+
.venv/bin/pytest # conformance (starts sdks/conformance/server.mjs with node), core, webhooks; 100% coverage
|
|
299
|
+
.venv/bin/mypy # --strict
|
|
300
|
+
node ../codegen/generate.mjs --only python # regenerate src/zapsmtp/_generated after an API change
|
|
301
|
+
```
|