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.
@@ -0,0 +1,12 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .coverage
7
+ .coverage.*
8
+ htmlcov/
9
+ coverage.xml
10
+ dist/
11
+ build/
12
+ *.egg-info/
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
+ ```
@@ -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
+ ```