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.
Files changed (28) hide show
  1. sendstack-0.1.1/PKG-INFO +541 -0
  2. sendstack-0.1.1/README.md +514 -0
  3. {sendstack-0.1.0 → sendstack-0.1.1}/pyproject.toml +3 -3
  4. sendstack-0.1.1/src/sendstack/__init__.py +124 -0
  5. sendstack-0.1.1/src/sendstack/client.py +1080 -0
  6. sendstack-0.1.1/src/sendstack/errors.py +112 -0
  7. sendstack-0.1.1/src/sendstack/types.py +364 -0
  8. {sendstack-0.1.0 → sendstack-0.1.1}/src/sendstack/utils.py +2 -2
  9. sendstack-0.1.1/src/sendstack.egg-info/PKG-INFO +541 -0
  10. {sendstack-0.1.0 → sendstack-0.1.1}/src/sendstack.egg-info/SOURCES.txt +1 -1
  11. sendstack-0.1.1/tests/test_conformance.py +108 -0
  12. sendstack-0.1.1/tests/test_sendstack.py +1014 -0
  13. sendstack-0.1.0/PKG-INFO +0 -725
  14. sendstack-0.1.0/README.md +0 -698
  15. sendstack-0.1.0/src/sendstack/__init__.py +0 -32
  16. sendstack-0.1.0/src/sendstack/client.py +0 -1790
  17. sendstack-0.1.0/src/sendstack/errors.py +0 -43
  18. sendstack-0.1.0/src/sendstack/types.py +0 -101
  19. sendstack-0.1.0/src/sendstack.egg-info/PKG-INFO +0 -725
  20. sendstack-0.1.0/tests/test_internal.py +0 -466
  21. sendstack-0.1.0/tests/test_sendstack.py +0 -1348
  22. {sendstack-0.1.0 → sendstack-0.1.1}/LICENSE +0 -0
  23. {sendstack-0.1.0 → sendstack-0.1.1}/setup.cfg +0 -0
  24. {sendstack-0.1.0 → sendstack-0.1.1}/src/sendstack/py.typed +0 -0
  25. {sendstack-0.1.0 → sendstack-0.1.1}/src/sendstack.egg-info/dependency_links.txt +0 -0
  26. {sendstack-0.1.0 → sendstack-0.1.1}/src/sendstack.egg-info/requires.txt +0 -0
  27. {sendstack-0.1.0 → sendstack-0.1.1}/src/sendstack.egg-info/top_level.txt +0 -0
  28. {sendstack-0.1.0 → sendstack-0.1.1}/tests/test_distribution_identity.py +0 -0
@@ -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
+ ```