sendstack 0.1.0__tar.gz → 0.1.1__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.
- sendstack-0.1.1/PKG-INFO +541 -0
- sendstack-0.1.1/README.md +514 -0
- {sendstack-0.1.0 → sendstack-0.1.1}/pyproject.toml +3 -3
- sendstack-0.1.1/src/sendstack/__init__.py +124 -0
- sendstack-0.1.1/src/sendstack/client.py +1080 -0
- sendstack-0.1.1/src/sendstack/errors.py +112 -0
- sendstack-0.1.1/src/sendstack/types.py +364 -0
- {sendstack-0.1.0 → sendstack-0.1.1}/src/sendstack/utils.py +2 -2
- sendstack-0.1.1/src/sendstack.egg-info/PKG-INFO +541 -0
- {sendstack-0.1.0 → sendstack-0.1.1}/src/sendstack.egg-info/SOURCES.txt +1 -1
- sendstack-0.1.1/tests/test_conformance.py +108 -0
- sendstack-0.1.1/tests/test_sendstack.py +1014 -0
- sendstack-0.1.0/PKG-INFO +0 -725
- sendstack-0.1.0/README.md +0 -698
- sendstack-0.1.0/src/sendstack/__init__.py +0 -32
- sendstack-0.1.0/src/sendstack/client.py +0 -1790
- sendstack-0.1.0/src/sendstack/errors.py +0 -43
- sendstack-0.1.0/src/sendstack/types.py +0 -101
- sendstack-0.1.0/src/sendstack.egg-info/PKG-INFO +0 -725
- sendstack-0.1.0/tests/test_internal.py +0 -466
- sendstack-0.1.0/tests/test_sendstack.py +0 -1348
- {sendstack-0.1.0 → sendstack-0.1.1}/LICENSE +0 -0
- {sendstack-0.1.0 → sendstack-0.1.1}/setup.cfg +0 -0
- {sendstack-0.1.0 → sendstack-0.1.1}/src/sendstack/py.typed +0 -0
- {sendstack-0.1.0 → sendstack-0.1.1}/src/sendstack.egg-info/dependency_links.txt +0 -0
- {sendstack-0.1.0 → sendstack-0.1.1}/src/sendstack.egg-info/requires.txt +0 -0
- {sendstack-0.1.0 → sendstack-0.1.1}/src/sendstack.egg-info/top_level.txt +0 -0
- {sendstack-0.1.0 → sendstack-0.1.1}/tests/test_distribution_identity.py +0 -0
sendstack-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sendstack
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Sync and async Python SDK for the SendStack email SaaS API.
|
|
5
|
+
Author: Noria Labs
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: sendstack,email,transactional-email,webhooks,httpx,async,sdk
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
16
|
+
Classifier: Topic :: Communications :: Email
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: httpx>=0.28.1
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=9.0.3; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-cov>=7.1.0; extra == "dev"
|
|
25
|
+
Requires-Dist: ruff>=0.15.9; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# `sendstack`
|
|
29
|
+
|
|
30
|
+
Official Python SDK for the SendStack email SaaS API.
|
|
31
|
+
|
|
32
|
+
Built on `httpx`, it ships both a synchronous client (`Sendstack`) and an
|
|
33
|
+
asynchronous client (`AsyncSendstack`) covering the SendStack developer API.
|
|
34
|
+
|
|
35
|
+
Use it for:
|
|
36
|
+
|
|
37
|
+
- transactional and scheduled email
|
|
38
|
+
- batch email
|
|
39
|
+
- reusable attachment uploads
|
|
40
|
+
- sending domains
|
|
41
|
+
- email templates
|
|
42
|
+
- webhook endpoints and webhook event retries
|
|
43
|
+
- suppression lists
|
|
44
|
+
|
|
45
|
+
Python `>=3.11` is required.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install sendstack
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
For local development:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
uv sync --extra dev
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Quick Start
|
|
60
|
+
|
|
61
|
+
### Sync
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import os
|
|
65
|
+
|
|
66
|
+
from sendstack import Sendstack, RequestOptions
|
|
67
|
+
|
|
68
|
+
token = os.environ["SENDSTACK_TOKEN"]
|
|
69
|
+
|
|
70
|
+
client = Sendstack(token)
|
|
71
|
+
|
|
72
|
+
message = client.emails.send(
|
|
73
|
+
{
|
|
74
|
+
"from": "Noria <hello@example.com>",
|
|
75
|
+
"to": "friend@example.com",
|
|
76
|
+
"subject": "Hello from SendStack",
|
|
77
|
+
"html": "<p>Your email pipeline is working.</p>",
|
|
78
|
+
"text": "Your email pipeline is working.",
|
|
79
|
+
},
|
|
80
|
+
RequestOptions(idempotency_key="welcome-email-1"),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
print(message["id"], message["status"])
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`Sendstack` opens an `httpx.Client` for you. Close it when you are done, or use
|
|
87
|
+
it as a context manager:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
with Sendstack(token) as client:
|
|
91
|
+
client.emails.send({"from": "hello@example.com", "to": "friend@example.com", "text": "Hi"})
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Async
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
import asyncio
|
|
98
|
+
import os
|
|
99
|
+
|
|
100
|
+
from sendstack import AsyncSendstack, RequestOptions
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def main() -> None:
|
|
104
|
+
async with AsyncSendstack(os.environ["SENDSTACK_TOKEN"]) as client:
|
|
105
|
+
message = await client.emails.send(
|
|
106
|
+
{
|
|
107
|
+
"from": "Noria <hello@example.com>",
|
|
108
|
+
"to": "friend@example.com",
|
|
109
|
+
"subject": "Hello from SendStack",
|
|
110
|
+
"text": "Your async pipeline is working.",
|
|
111
|
+
},
|
|
112
|
+
RequestOptions(idempotency_key="welcome-email-1"),
|
|
113
|
+
)
|
|
114
|
+
print(message["id"], message["status"])
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
asyncio.run(main())
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Every example below works on both clients. On `Sendstack` a method returns the
|
|
121
|
+
decoded payload; on `AsyncSendstack` the same call returns a coroutine you
|
|
122
|
+
`await`.
|
|
123
|
+
|
|
124
|
+
### Base URL
|
|
125
|
+
|
|
126
|
+
The SDK defaults to `https://mailer.norialabs.com`, matching the current live
|
|
127
|
+
API. Override it when you move to the SendStack domain:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
client = Sendstack(token, base_url="https://sendstack.norialabs.com")
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Docs Split
|
|
134
|
+
|
|
135
|
+
This README is the package guide: install, initialization, SDK methods, request
|
|
136
|
+
options, errors, and examples.
|
|
137
|
+
|
|
138
|
+
The SaaS docs remain the canonical source for product/API behavior: account
|
|
139
|
+
setup, API tokens, domain verification, DNS records, webhook event catalogs,
|
|
140
|
+
deliverability concepts, provider behavior, dashboard workflows, and the raw
|
|
141
|
+
HTTP API reference. Current live SaaS docs are at
|
|
142
|
+
`https://mailer.norialabs.com/api/docs`.
|
|
143
|
+
|
|
144
|
+
## Auth
|
|
145
|
+
|
|
146
|
+
The API uses bearer auth:
|
|
147
|
+
|
|
148
|
+
```http
|
|
149
|
+
Authorization: Bearer <token>
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Passing a token as the first argument configures that header automatically:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
client = Sendstack("mlr_live_...")
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
You can also pass a custom auth strategy. Tokens may be static or resolved per
|
|
159
|
+
request (sync or async callables both work):
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from sendstack import BearerAuthStrategy, Sendstack
|
|
163
|
+
|
|
164
|
+
client = Sendstack(
|
|
165
|
+
base_url="https://mailer.norialabs.com",
|
|
166
|
+
auth=BearerAuthStrategy(token=lambda context: get_fresh_token()),
|
|
167
|
+
)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Use `HeadersAuthStrategy` when the target environment expects custom headers,
|
|
171
|
+
and `RequestOptions(authenticated=False)` to strip auth for a single call.
|
|
172
|
+
|
|
173
|
+
## Method Reference
|
|
174
|
+
|
|
175
|
+
| SDK method | HTTP route | Returns |
|
|
176
|
+
| --- | --- | --- |
|
|
177
|
+
| `attachments.upload(payload, options=None)` | `POST /attachments` | `UploadedAttachment` |
|
|
178
|
+
| `emails.send(payload, options=None)` | `POST /emails` | `SendEmailResult` |
|
|
179
|
+
| `emails.send_batch(payload, options=None)` | `POST /emails/batch` | `SendEmailBatchResult` |
|
|
180
|
+
| `emails.list(options=None, *, limit=None, cursor=None, status=None)` | `GET /emails` | `CursorPage` |
|
|
181
|
+
| `emails.get(message_id, options=None)` | `GET /emails/{id}` | `EmailMessage` |
|
|
182
|
+
| `emails.events(message_id, options=None)` | `GET /emails/{id}/events` | `CursorPage` |
|
|
183
|
+
| `emails.cancel(message_id, options=None)` | `POST /emails/{id}/cancel` | `EmailMessage` |
|
|
184
|
+
| `emails.requeue(message_id, options=None)` | `POST /emails/{id}/requeue` | `EmailMessage` |
|
|
185
|
+
| `domains.create(payload, options=None)` | `POST /domains` | `Domain` |
|
|
186
|
+
| `domains.list(options=None)` | `GET /domains` | `CursorPage` |
|
|
187
|
+
| `domains.get(domain_id, options=None)` | `GET /domains/{id}` | `Domain` |
|
|
188
|
+
| `domains.verify(domain_id, options=None)` | `POST /domains/{id}/verify` | `Domain` |
|
|
189
|
+
| `templates.create(payload, options=None)` | `POST /templates` | `EmailTemplate` |
|
|
190
|
+
| `templates.list(options=None)` | `GET /templates` | `CursorPage` |
|
|
191
|
+
| `templates.get(template_id, options=None)` | `GET /templates/{id}` | `EmailTemplate` |
|
|
192
|
+
| `templates.update(template_id, payload, options=None)` | `PATCH /templates/{id}` | `EmailTemplate` |
|
|
193
|
+
| `templates.remove(template_id, options=None)` | `DELETE /templates/{id}` | `None` |
|
|
194
|
+
| `webhooks.create(payload, options=None)` | `POST /webhook-endpoints` | `WebhookEndpoint` |
|
|
195
|
+
| `webhooks.list(options=None)` | `GET /webhook-endpoints` | `CursorPage` |
|
|
196
|
+
| `webhooks.update(webhook_id, payload, options=None)` | `PATCH /webhook-endpoints/{id}` | `WebhookEndpoint` |
|
|
197
|
+
| `webhooks.remove(webhook_id, options=None)` | `DELETE /webhook-endpoints/{id}` | `None` |
|
|
198
|
+
| `webhook_events.retry(event_id, options=None)` | `POST /events/{id}/retry` | `RetryWebhookEventResult` |
|
|
199
|
+
| `suppressions.add(payload, options=None)` | `POST /suppressions` | `CreateSuppressionResult` |
|
|
200
|
+
| `suppressions.list(options=None)` | `GET /suppressions` | `CursorPage` |
|
|
201
|
+
| `suppressions.remove(recipient, options=None)` | `DELETE /suppressions/{recipient}` | `None` |
|
|
202
|
+
|
|
203
|
+
`webhook_events` is also available as `webhookEvents`, and `emails.send_batch`
|
|
204
|
+
as `emails.sendBatch`, as convenience camelCase aliases.
|
|
205
|
+
|
|
206
|
+
Methods take a plain `dict` payload and return plain `dict` responses (the
|
|
207
|
+
`{"ok": true, "data": ...}` envelope is unwrapped for you). The exported model
|
|
208
|
+
types — `EmailMessage`, `Domain`, `WebhookEndpoint`, and friends — are optional
|
|
209
|
+
typing aids you can annotate with.
|
|
210
|
+
|
|
211
|
+
## Emails
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
client.emails.send(
|
|
215
|
+
{
|
|
216
|
+
"from": "hello@example.com",
|
|
217
|
+
"to": ["a@example.com", "b@example.com"],
|
|
218
|
+
"reply_to": "support@example.com",
|
|
219
|
+
"subject": "Welcome",
|
|
220
|
+
"html": "<p>Hello</p>",
|
|
221
|
+
"text": "Hello",
|
|
222
|
+
"tags": [{"name": "campaign", "value": "welcome"}],
|
|
223
|
+
"metadata": {"account": "acct_123"},
|
|
224
|
+
"track_opens": True,
|
|
225
|
+
"track_clicks": True,
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
`to`, `cc`, `bcc`, and `reply_to` accept a single address or a list.
|
|
231
|
+
|
|
232
|
+
Batch sends accept either a list or `{"emails": [...]}`:
|
|
233
|
+
|
|
234
|
+
```python
|
|
235
|
+
client.emails.send_batch(
|
|
236
|
+
[
|
|
237
|
+
{"from": "hello@example.com", "to": "a@example.com", "subject": "One", "text": "First"},
|
|
238
|
+
{"from": "hello@example.com", "to": "b@example.com", "subject": "Two", "text": "Second"},
|
|
239
|
+
]
|
|
240
|
+
)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
List, fetch, inspect events, then cancel or requeue:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
page = client.emails.list(limit=25, status="queued")
|
|
247
|
+
message = client.emails.get("msg_123")
|
|
248
|
+
events = client.emails.events("msg_123")
|
|
249
|
+
client.emails.cancel("msg_123")
|
|
250
|
+
client.emails.requeue("msg_123")
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### Scheduled email
|
|
254
|
+
|
|
255
|
+
`scheduled_at` accepts an ISO-8601 string or a `datetime` (serialized to
|
|
256
|
+
ISO-8601 with a `Z` suffix):
|
|
257
|
+
|
|
258
|
+
```python
|
|
259
|
+
from datetime import datetime, timezone
|
|
260
|
+
|
|
261
|
+
client.emails.send(
|
|
262
|
+
{
|
|
263
|
+
"from": "hello@example.com",
|
|
264
|
+
"to": "friend@example.com",
|
|
265
|
+
"subject": "Later",
|
|
266
|
+
"text": "Scheduled.",
|
|
267
|
+
"scheduled_at": datetime(2026, 3, 28, 9, 0, tzinfo=timezone.utc),
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## Attachments
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
attachment = client.attachments.upload(
|
|
276
|
+
{
|
|
277
|
+
"filename": "invoice.pdf",
|
|
278
|
+
"content_base64": invoice_pdf_base64,
|
|
279
|
+
"content_type": "application/pdf",
|
|
280
|
+
}
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
client.emails.send(
|
|
284
|
+
{
|
|
285
|
+
"from": "billing@example.com",
|
|
286
|
+
"to": "customer@example.com",
|
|
287
|
+
"subject": "Invoice",
|
|
288
|
+
"text": "Attached.",
|
|
289
|
+
"attachments": [
|
|
290
|
+
{"filename": "invoice.pdf", "attachment_id": attachment["attachment_id"]},
|
|
291
|
+
],
|
|
292
|
+
}
|
|
293
|
+
)
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
## Domains
|
|
297
|
+
|
|
298
|
+
```python
|
|
299
|
+
domain = client.domains.create(
|
|
300
|
+
{
|
|
301
|
+
"domain": "example.com",
|
|
302
|
+
"region": "af-south-1",
|
|
303
|
+
"tls": "enforced",
|
|
304
|
+
"capabilities": {"sending": "enabled"},
|
|
305
|
+
}
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
client.domains.verify(domain["id"])
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
## Templates
|
|
312
|
+
|
|
313
|
+
```python
|
|
314
|
+
template = client.templates.create(
|
|
315
|
+
{
|
|
316
|
+
"name": "Welcome",
|
|
317
|
+
"slug": "welcome",
|
|
318
|
+
"subject": "Welcome, {{firstName}}",
|
|
319
|
+
"html": "<p>Hello {{firstName}}</p>",
|
|
320
|
+
"text": "Hello {{firstName}}",
|
|
321
|
+
}
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
client.emails.send(
|
|
325
|
+
{
|
|
326
|
+
"from": "hello@example.com",
|
|
327
|
+
"to": "friend@example.com",
|
|
328
|
+
"template": {"id": template["id"], "variables": {"firstName": "Amina"}},
|
|
329
|
+
}
|
|
330
|
+
)
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
## Webhooks
|
|
334
|
+
|
|
335
|
+
```python
|
|
336
|
+
endpoint = client.webhooks.create(
|
|
337
|
+
{
|
|
338
|
+
"url": "https://example.com/webhooks/sendstack",
|
|
339
|
+
"event_types": ["email.sent", "email.failed"],
|
|
340
|
+
}
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
client.webhook_events.retry("event_123")
|
|
344
|
+
client.webhooks.update(endpoint["id"], {"enabled": False})
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
## Suppressions
|
|
348
|
+
|
|
349
|
+
```python
|
|
350
|
+
client.suppressions.add({"recipient": "bad@example.com", "reason": "manual"})
|
|
351
|
+
|
|
352
|
+
suppressions = client.suppressions.list()
|
|
353
|
+
client.suppressions.remove("bad@example.com")
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
## Field Aliases
|
|
357
|
+
|
|
358
|
+
Python users write idiomatic snake_case, which is exactly the wire format for
|
|
359
|
+
email, attachment, webhook, and domain fields — `reply_to`, `track_opens`,
|
|
360
|
+
`track_clicks`, `provider_id`, `template_id`, `template_data`, `scheduled_at`,
|
|
361
|
+
`content_base64`, `content_type`, `attachment_id`, `content_id`, `event_types`,
|
|
362
|
+
`custom_return_path`. No translation needed.
|
|
363
|
+
|
|
364
|
+
The camelCase aliases (`replyTo`, `trackOpens`,
|
|
365
|
+
`trackClicks`, `providerId`, `templateId`, `templateData`, `scheduledAt`,
|
|
366
|
+
`contentBase64`, `attachmentId`, `eventTypes`, …) are also accepted and
|
|
367
|
+
converted to the wire field names. Everything else is passed through unchanged,
|
|
368
|
+
so the SDK stays forward-compatible with new API fields.
|
|
369
|
+
|
|
370
|
+
## Request Options
|
|
371
|
+
|
|
372
|
+
All methods accept a `RequestOptions`. Mutating methods also honor
|
|
373
|
+
`idempotency_key`.
|
|
374
|
+
|
|
375
|
+
```python
|
|
376
|
+
from sendstack import RequestOptions
|
|
377
|
+
|
|
378
|
+
client.emails.send(
|
|
379
|
+
{
|
|
380
|
+
"from": "hello@example.com",
|
|
381
|
+
"to": "friend@example.com",
|
|
382
|
+
"subject": "Hello",
|
|
383
|
+
"text": "Hello",
|
|
384
|
+
},
|
|
385
|
+
RequestOptions(
|
|
386
|
+
idempotency_key="email-123",
|
|
387
|
+
timeout_seconds=10.0,
|
|
388
|
+
query={"debug": True},
|
|
389
|
+
headers={"x-tenant-id": "tenant_123"},
|
|
390
|
+
),
|
|
391
|
+
)
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Supported options:
|
|
395
|
+
|
|
396
|
+
- `headers`: extra headers (merged over constructor headers)
|
|
397
|
+
- `query`: per-request query params (merged over constructor query)
|
|
398
|
+
- `timeout_seconds`: request timeout, default `30.0`
|
|
399
|
+
- `authenticated`: set `False` to strip auth headers for a request
|
|
400
|
+
- `auth`: a `BearerAuthStrategy` / `HeadersAuthStrategy`, or `False`
|
|
401
|
+
- `retry`: a `RetryOptions`, an attempt count, or `False`
|
|
402
|
+
- `middleware`: request/response middleware (runs after constructor middleware)
|
|
403
|
+
- `parse_response`: custom response parser
|
|
404
|
+
- `transform_response`: custom response transformer
|
|
405
|
+
- `unwrap_data`: unwrap `{"ok": true, "data": ...}` envelopes, default `True`
|
|
406
|
+
- `client`: a per-request `httpx` client
|
|
407
|
+
- `idempotency_key`: sets the `Idempotency-Key` header
|
|
408
|
+
- `body`: raw body for `request(...)`
|
|
409
|
+
|
|
410
|
+
## Retries
|
|
411
|
+
|
|
412
|
+
Retries are off by default (a single attempt). Enable them per request or on the
|
|
413
|
+
client:
|
|
414
|
+
|
|
415
|
+
```python
|
|
416
|
+
from sendstack import RequestOptions, RetryOptions
|
|
417
|
+
|
|
418
|
+
client.emails.list(
|
|
419
|
+
RequestOptions(retry=RetryOptions(max_attempts=3, delay_seconds=0.5))
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# Or just an attempt count:
|
|
423
|
+
client.emails.list(RequestOptions(retry=3))
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
Default retry behavior:
|
|
427
|
+
|
|
428
|
+
- retries network exceptions, unless they are already a `SendstackError`
|
|
429
|
+
- retries responses only for `408`, `425`, `429`, `500`, `502`, `503`, `504`
|
|
430
|
+
- uses a short exponential backoff when no custom `delay_seconds` is given
|
|
431
|
+
|
|
432
|
+
`RetryOptions` accepts `delay_seconds` and `should_retry` as values or callables
|
|
433
|
+
(sync or async).
|
|
434
|
+
|
|
435
|
+
## Custom `httpx` Clients
|
|
436
|
+
|
|
437
|
+
Inject your own `httpx.Client` or `httpx.AsyncClient`:
|
|
438
|
+
|
|
439
|
+
```python
|
|
440
|
+
import httpx
|
|
441
|
+
|
|
442
|
+
from sendstack import Sendstack
|
|
443
|
+
|
|
444
|
+
client = Sendstack("mlr_live_...", client=httpx.Client(timeout=5.0))
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
If you inject a client, the SDK uses it but does not close it for you.
|
|
448
|
+
|
|
449
|
+
## Middleware
|
|
450
|
+
|
|
451
|
+
```python
|
|
452
|
+
from sendstack import Sendstack
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def add_sdk_header(context, next_call):
|
|
456
|
+
context.headers["x-sdk"] = "sendstack-python"
|
|
457
|
+
return next_call(context)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
client = Sendstack("mlr_live_...", middleware=[add_sdk_header])
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
Middleware can mutate headers, rewrite the final URL, or short-circuit a request
|
|
464
|
+
by returning a response context without calling `next_call`.
|
|
465
|
+
|
|
466
|
+
## Lower-Level Request
|
|
467
|
+
|
|
468
|
+
Every resource method uses `request(...)` internally. Use it directly for new
|
|
469
|
+
API routes before the SDK grows a typed wrapper:
|
|
470
|
+
|
|
471
|
+
```python
|
|
472
|
+
from sendstack import RequestOptions
|
|
473
|
+
|
|
474
|
+
result = client.request("GET", "/emails", RequestOptions(query={"limit": 25, "status": "queued"}))
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
Pass `RequestOptions(unwrap_data=False)` if you need the raw `{"ok", "data"}`
|
|
478
|
+
envelope.
|
|
479
|
+
|
|
480
|
+
## Errors
|
|
481
|
+
|
|
482
|
+
Failed responses raise `SendstackError`.
|
|
483
|
+
|
|
484
|
+
```python
|
|
485
|
+
from sendstack import SendstackError
|
|
486
|
+
|
|
487
|
+
try:
|
|
488
|
+
client.emails.send({"from": "hello@example.com", "to": "bad", "subject": "Hi", "text": "Hi"})
|
|
489
|
+
except SendstackError as error:
|
|
490
|
+
print(error.status_code, error.code, error.message, error.details)
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
`SendstackError` includes:
|
|
494
|
+
|
|
495
|
+
- `status_code`
|
|
496
|
+
- `code`
|
|
497
|
+
- `details`
|
|
498
|
+
- `response_body`
|
|
499
|
+
- `message`
|
|
500
|
+
|
|
501
|
+
It understands SendStack envelopes (`{"ok": false, "error": {...}}`) as well as
|
|
502
|
+
FastAPI-style `{"detail": "..."}` bodies.
|
|
503
|
+
|
|
504
|
+
## Exports
|
|
505
|
+
|
|
506
|
+
Clients and errors:
|
|
507
|
+
|
|
508
|
+
- `Sendstack`, `AsyncSendstack`
|
|
509
|
+
- `SendstackClient`, `AsyncSendstackClient` (aliases)
|
|
510
|
+
- `SendstackError`
|
|
511
|
+
- `DEFAULT_BASE_URL`
|
|
512
|
+
|
|
513
|
+
Auth, options, and machinery:
|
|
514
|
+
|
|
515
|
+
- `BearerAuthStrategy`, `HeadersAuthStrategy`, `SendstackAuthStrategy`
|
|
516
|
+
- `RequestOptions`, `RetryOptions`
|
|
517
|
+
- `SendstackMiddleware`, `ResponseParser`, `ResponseTransformer`
|
|
518
|
+
- `SendstackRequestContext`, `SendstackResponseContext`, `SendstackRetryContext`
|
|
519
|
+
|
|
520
|
+
Model types (optional typing aids):
|
|
521
|
+
|
|
522
|
+
- `Recipient`, `SendstackTag`, `TemplateReference`
|
|
523
|
+
- `SendEmailRequest`, `SendEmailResult`, `SendEmailBatchResult`
|
|
524
|
+
- `EmailMessage`, `EmailEvent`, `EmailStatus`
|
|
525
|
+
- `UploadAttachmentRequest`, `UploadedAttachment`
|
|
526
|
+
- `CreateDomainRequest`, `Domain`, `DomainRegion`, `DomainTlsPolicy`, `DomainCapability`
|
|
527
|
+
- `CreateTemplateRequest`, `UpdateTemplateRequest`, `EmailTemplate`
|
|
528
|
+
- `CreateWebhookEndpointRequest`, `UpdateWebhookEndpointRequest`, `WebhookEndpoint`
|
|
529
|
+
- `WebhookEventType`, `KnownWebhookEvent`
|
|
530
|
+
- `RetryWebhookEventResult`
|
|
531
|
+
- `CreateSuppressionRequest`, `CreateSuppressionResult`, `Suppression`, `SuppressionReason`
|
|
532
|
+
- `CursorPage`
|
|
533
|
+
|
|
534
|
+
## Development
|
|
535
|
+
|
|
536
|
+
```bash
|
|
537
|
+
uv sync --extra dev
|
|
538
|
+
uv run ruff check .
|
|
539
|
+
uv run pytest
|
|
540
|
+
uv build
|
|
541
|
+
```
|