anypost 1.0.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.
Files changed (37) hide show
  1. anypost-1.0.0/.gitignore +17 -0
  2. anypost-1.0.0/LICENSE +21 -0
  3. anypost-1.0.0/PKG-INFO +422 -0
  4. anypost-1.0.0/README.md +390 -0
  5. anypost-1.0.0/pyproject.toml +77 -0
  6. anypost-1.0.0/src/anypost/__init__.py +75 -0
  7. anypost-1.0.0/src/anypost/_async/__init__.py +7 -0
  8. anypost-1.0.0/src/anypost/_async/client.py +109 -0
  9. anypost-1.0.0/src/anypost/_async/resources.py +346 -0
  10. anypost-1.0.0/src/anypost/_async_http.py +96 -0
  11. anypost-1.0.0/src/anypost/_base64.py +10 -0
  12. anypost-1.0.0/src/anypost/_client.py +110 -0
  13. anypost-1.0.0/src/anypost/_exceptions.py +234 -0
  14. anypost-1.0.0/src/anypost/_http.py +176 -0
  15. anypost-1.0.0/src/anypost/_pagination.py +92 -0
  16. anypost-1.0.0/src/anypost/_version.py +3 -0
  17. anypost-1.0.0/src/anypost/_webhook_signature.py +171 -0
  18. anypost-1.0.0/src/anypost/py.typed +0 -0
  19. anypost-1.0.0/src/anypost/resources/__init__.py +23 -0
  20. anypost-1.0.0/src/anypost/resources/api_keys.py +62 -0
  21. anypost-1.0.0/src/anypost/resources/domains.py +62 -0
  22. anypost-1.0.0/src/anypost/resources/email.py +76 -0
  23. anypost-1.0.0/src/anypost/resources/events.py +53 -0
  24. anypost-1.0.0/src/anypost/resources/identity.py +17 -0
  25. anypost-1.0.0/src/anypost/resources/suppressions.py +86 -0
  26. anypost-1.0.0/src/anypost/resources/templates.py +86 -0
  27. anypost-1.0.0/src/anypost/resources/webhooks.py +82 -0
  28. anypost-1.0.0/src/anypost/types/__init__.py +117 -0
  29. anypost-1.0.0/src/anypost/types/api_key.py +56 -0
  30. anypost-1.0.0/src/anypost/types/common.py +55 -0
  31. anypost-1.0.0/src/anypost/types/domain.py +87 -0
  32. anypost-1.0.0/src/anypost/types/email.py +135 -0
  33. anypost-1.0.0/src/anypost/types/event.py +99 -0
  34. anypost-1.0.0/src/anypost/types/identity.py +27 -0
  35. anypost-1.0.0/src/anypost/types/suppression.py +62 -0
  36. anypost-1.0.0/src/anypost/types/template.py +88 -0
  37. anypost-1.0.0/src/anypost/types/webhook.py +82 -0
@@ -0,0 +1,17 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ venv/
10
+ .env
11
+
12
+ # Tooling caches
13
+ .pytest_cache/
14
+ .mypy_cache/
15
+ .ruff_cache/
16
+ .coverage
17
+ htmlcov/
anypost-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 unMTA LLC
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.
anypost-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,422 @@
1
+ Metadata-Version: 2.4
2
+ Name: anypost
3
+ Version: 1.0.0
4
+ Summary: Official Python SDK for the Anypost email API.
5
+ Project-URL: Homepage, https://anypost.com
6
+ Project-URL: Documentation, https://anypost.com/docs
7
+ Project-URL: Repository, https://github.com/anypost/anypost-python
8
+ Project-URL: Issues, https://github.com/anypost/anypost-python/issues
9
+ Author: Anypost
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: anypost,api,email,smtp,transactional-email
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
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: Typing :: Typed
23
+ Requires-Python: >=3.9
24
+ Requires-Dist: httpx>=0.25
25
+ Requires-Dist: typing-extensions>=4.5
26
+ Provides-Extra: dev
27
+ Requires-Dist: mypy>=1.8; extra == 'dev'
28
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
29
+ Requires-Dist: pytest>=8; extra == 'dev'
30
+ Requires-Dist: ruff>=0.4; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # Anypost Python SDK
34
+
35
+ The official Python client for the [Anypost](https://anypost.com) email API.
36
+
37
+ Requires Python 3.9+. Built on [httpx](https://www.python-httpx.org/), with sync and async clients.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install anypost
43
+ ```
44
+
45
+ ## Quickstart
46
+
47
+ ```python
48
+ from anypost import Anypost
49
+
50
+ client = Anypost("ap_your_api_key")
51
+
52
+ result = client.email.send({
53
+ "from": "Acme <you@yourdomain.com>",
54
+ "to": ["someone@example.com"],
55
+ "subject": "Hello from Anypost",
56
+ "html": "<p>It worked.</p>",
57
+ })
58
+ print(result["id"])
59
+ ```
60
+
61
+ The constructor also reads `ANYPOST_API_KEY` from the environment:
62
+
63
+ ```python
64
+ client = Anypost()
65
+ ```
66
+
67
+ Keep the key server-side. It is a bearer credential; never ship it to a browser or mobile app.
68
+
69
+ Request bodies are plain dicts that match the API one-to-one — including the `"from"` key, which a dict literal accepts even though `from` is a reserved word. Responses are dicts too, typed with `TypedDict` for editor autocomplete.
70
+
71
+ ## Async
72
+
73
+ Every operation has an async twin on `AsyncAnypost`. Same methods, same shapes — `await` them, and iterate pages with `async for`.
74
+
75
+ ```python
76
+ import asyncio
77
+ from anypost import AsyncAnypost
78
+
79
+ async def main():
80
+ async with AsyncAnypost("ap_your_api_key") as client:
81
+ result = await client.email.send({
82
+ "from": "Acme <you@yourdomain.com>",
83
+ "to": ["someone@example.com"],
84
+ "subject": "Hello",
85
+ "html": "<p>It worked.</p>",
86
+ })
87
+ print(result["id"])
88
+
89
+ asyncio.run(main())
90
+ ```
91
+
92
+ The rest of this README shows the sync client. The async client is identical with `await`.
93
+
94
+ ## Sending
95
+
96
+ One of `text`, `html`, or `template_id` is required. All recipients in `to`, `cc`, and `bcc` share one envelope and count against a combined limit of 50.
97
+
98
+ ```python
99
+ client.email.send({
100
+ "from": "Acme <you@yourdomain.com>",
101
+ "to": ["a@example.com", "b@example.com"],
102
+ "cc": ["team@example.com"],
103
+ "reply_to": "support@yourdomain.com",
104
+ "subject": "Receipt #4823",
105
+ "html": "<p>Thanks for your order.</p>",
106
+ "text": "Thanks for your order.",
107
+ "tags": ["receipt"],
108
+ })
109
+ ```
110
+
111
+ Pass attachment `content` as raw `bytes` and the client base64-encodes it; pass an already-encoded `str` and it is sent as-is. The request body is capped at 5 MB.
112
+
113
+ ```python
114
+ from pathlib import Path
115
+
116
+ client.email.send({
117
+ "from": "you@yourdomain.com",
118
+ "to": ["someone@example.com"],
119
+ "subject": "Your report",
120
+ "text": "Attached.",
121
+ "attachments": [
122
+ {"filename": "report.pdf", "content": Path("report.pdf").read_bytes()},
123
+ ],
124
+ })
125
+ ```
126
+
127
+ Send with a published template and per-recipient variables:
128
+
129
+ ```python
130
+ client.email.send({
131
+ "from": "you@yourdomain.com",
132
+ "to": ["someone@example.com"],
133
+ "template_id": "template_018f2c5e-3a40-7a91-9c25-3a0b1d5e6f78",
134
+ "variables": {"name": "Ada", "plan": "pro"},
135
+ })
136
+ ```
137
+
138
+ ## Batch
139
+
140
+ Send 1 to 100 independent messages in one request. `defaults` fills any field an entry omits.
141
+
142
+ ```python
143
+ result = client.email.send_batch({
144
+ "defaults": {"from": "you@yourdomain.com"},
145
+ "emails": [
146
+ {"to": ["a@example.com"], "subject": "Hi A", "text": "..."},
147
+ {"to": ["b@example.com"], "subject": "Hi B", "text": "..."},
148
+ ],
149
+ })
150
+ ```
151
+
152
+ A batch with mixed outcomes returns HTTP `207` and resolves normally. Inspect each entry rather than relying on a raised error:
153
+
154
+ ```python
155
+ print(result["summary"]) # {"total": ..., "queued": ..., "failed": ...}
156
+
157
+ for entry in result["data"]:
158
+ if entry["status"] == "queued":
159
+ print(entry["index"], entry["id"])
160
+ else:
161
+ print(entry["index"], entry["error"]["type"], entry["error"]["message"])
162
+ ```
163
+
164
+ ## Domains
165
+
166
+ Manage sending domains under `client.domains`. Add a domain, publish the CNAMEs it returns, then verify.
167
+
168
+ ```python
169
+ domain = client.domains.create({"name": "example.com"})
170
+
171
+ for record in domain["dns_records"]:
172
+ print(record["type"], record["name"], "->", record["value"])
173
+ ```
174
+
175
+ `verify` always returns the current domain — a still-`pending` domain does not raise. Read `status` and `verification_failure`, and poll while DNS propagates.
176
+
177
+ ```python
178
+ checked = client.domains.verify(domain["id"])
179
+ if checked["status"] != "verified":
180
+ print(checked["verification_failure"])
181
+ ```
182
+
183
+ `get`, `update` (tracking config only), and `delete` round out the resource:
184
+
185
+ ```python
186
+ client.domains.update(domain["id"], {
187
+ "tracking": {"opens_enabled": True, "clicks_enabled": True, "subdomain": "track"},
188
+ })
189
+ client.domains.delete(domain["id"])
190
+ ```
191
+
192
+ ## API keys
193
+
194
+ Manage keys under `client.api_keys`. The plaintext secret comes back only once, on `create`, as `key`:
195
+
196
+ ```python
197
+ created = client.api_keys.create({
198
+ "name": "Production server",
199
+ "permissions": "send_only",
200
+ "allowed_domains": ["example.com"],
201
+ })
202
+ print(created["key"]) # store now; never retrievable again
203
+
204
+ client.api_keys.update(created["id"], {"name": "Production server", "permissions": "full"})
205
+ client.api_keys.delete(created["id"])
206
+ ```
207
+
208
+ `get` returns metadata only — `key_prefix`, never the secret. Permission and restriction changes take up to 5 minutes to propagate through the gateway cache.
209
+
210
+ ## Templates
211
+
212
+ Templates use a draft/published model: edits land in a draft, and `publish` promotes it. A template can't be used for sending until it's published.
213
+
214
+ ```python
215
+ template = client.templates.create({
216
+ "name": "Welcome email",
217
+ "kind": "html",
218
+ "html": "<h1>Welcome, {{ name }}</h1>",
219
+ })
220
+
221
+ client.templates.update_draft(template["id"], {
222
+ "subject": "Welcome to Acme",
223
+ "html": "<h1>Welcome, {{ name }}</h1>",
224
+ })
225
+ client.templates.publish(template["id"])
226
+ ```
227
+
228
+ `kind` is `html` or `markdown` and is immutable once set. The plain-text body is always derived server-side. `get_draft`, `delete_draft`, `duplicate`, `get`, `update` (name only), and `delete` round out the resource. Send with a published template via `template_id` (see [Sending](#sending)).
229
+
230
+ ## Suppressions
231
+
232
+ A suppression blocks sends to an address, scoped to a `topic`. The wildcard `*` blocks every topic; a specific topic (e.g. `marketing`) leaves transactional traffic untouched. Bounces and complaints write `*` automatically.
233
+
234
+ ```python
235
+ client.suppressions.create({
236
+ "email": "alice@example.com",
237
+ "topic": "marketing",
238
+ "note": "Customer requested removal",
239
+ })
240
+
241
+ row = client.suppressions.get("alice@example.com", "*")
242
+ client.suppressions.delete("alice@example.com", "marketing")
243
+ ```
244
+
245
+ `list` accepts `email_contains`, `topic`, `reason`, and `origin` filters. `list_for_email` returns every row for an address across all topics; `delete_for_email` removes them all.
246
+
247
+ ```python
248
+ for s in client.suppressions.list({"reason": "complaint"}):
249
+ print(s["email"], s["topic"], s["suppressed_at"])
250
+ ```
251
+
252
+ ## Webhooks
253
+
254
+ Manage webhook subscriptions under `client.webhooks`. The `signing_secret` comes back only once, on `create`; later reads return only `signing_secret_prefix`.
255
+
256
+ ```python
257
+ webhook = client.webhooks.create({
258
+ "name": "Production events",
259
+ "url": "https://hooks.example.com/anypost",
260
+ "events": ["email.delivered", "email.bounced", "email.complained"],
261
+ })
262
+ print(webhook["signing_secret"]) # store now; never retrievable again
263
+ ```
264
+
265
+ `update` sets the name, URL, events, and `status` together — set `status` to `"disabled"` to pause delivery, `"active"` to resume. `test` sends one synthetic `webhook.test` event and returns the outcome even when the endpoint fails. `rotate_secret` issues a new secret and keeps the previous one valid for a 24-hour grace window; `get`, `list`, and `delete` round out the resource.
266
+
267
+ ```python
268
+ result = client.webhooks.test(webhook["id"])
269
+ if not result["delivered"]:
270
+ print(result["status_code"], result["error"])
271
+
272
+ rotated = client.webhooks.rotate_secret(webhook["id"])
273
+ ```
274
+
275
+ ### Verifying deliveries
276
+
277
+ `verify_webhook_signature` is a standalone function — it needs the signing secret, not an API key, so call it in your handler without a client. Pass the **raw** request body (the exact bytes, before JSON parsing), the `Anypost-Signature` header, and the secret. It returns on success and raises `WebhookVerificationError` otherwise. `unwrap_webhook_event` does the same and returns the parsed delivery.
278
+
279
+ ```python
280
+ from anypost import unwrap_webhook_event, WebhookVerificationError
281
+
282
+ try:
283
+ delivery = unwrap_webhook_event(raw_body, signature_header, secret)
284
+ for event in delivery["events"]:
285
+ print(event["type"], event["data"]["email_id"])
286
+ except WebhookVerificationError as err:
287
+ # err.reason: "no_match" | "timestamp_out_of_tolerance" | ...
288
+ return Response(status_code=400)
289
+ ```
290
+
291
+ Reach for `verify_webhook_signature` when something else has already parsed the body. Keep the raw bytes for the verify step, then use your framework's parsed object once it passes:
292
+
293
+ ```python
294
+ from anypost import verify_webhook_signature, WebhookVerificationError
295
+
296
+ @app.post("/anypost")
297
+ async def anypost_webhook(request):
298
+ raw = await request.body()
299
+ try:
300
+ verify_webhook_signature(raw, request.headers["anypost-signature"], secret)
301
+ except WebhookVerificationError:
302
+ return Response(status_code=400)
303
+ for event in (await request.json())["events"]:
304
+ handle(event)
305
+ return Response(status_code=204)
306
+ ```
307
+
308
+ Deliveries older than five minutes are rejected by default to bound replay; pass `tolerance_seconds` to widen, narrow, or disable (`0`) that check. During a secret rotation the header carries a `v1=` component per active secret, and a match on any one passes — so deliveries keep verifying while you redeploy.
309
+
310
+ ## Events
311
+
312
+ `client.events.list` pages the team's event stream, newest-first. The window defaults to the last 24 hours and is clamped to your plan's retention. Events are read-only and not addressable by id — there is no `get`.
313
+
314
+ ```python
315
+ for event in client.events.list({"event_type": "email.bounced"}):
316
+ print(event["occurred_at"], event["recipient"], event["bounce_classification"])
317
+ ```
318
+
319
+ Filter by `start`, `end`, `event_type`, `recipient`, `email_id`, `message_id`, `domain`, `topic`, `campaign`, `template_id`, and `tags`. All filters are exact-match, except `tags`, which takes a list and matches an event carrying *any* of the given tags. A filter value that matches no row returns an empty page. This is also how you backfill the gap after a webhook endpoint was disabled — page the events that occurred during the outage once it's healthy.
320
+
321
+ ```python
322
+ # Events tagged "onboarding" OR "welcome", that also bounced.
323
+ page = client.events.list({
324
+ "tags": ["onboarding", "welcome"],
325
+ "event_type": "email.bounced",
326
+ })
327
+ ```
328
+
329
+ ## Pagination
330
+
331
+ List endpoints return a `Page`. Read one page directly, or iterate it to walk every page — the client fetches each one as needed.
332
+
333
+ ```python
334
+ page = client.domains.list({"limit": 50})
335
+ page.data # this page's items
336
+ page.has_more # whether another page exists
337
+ page.next_cursor # pass as "after" to fetch it yourself
338
+
339
+ for domain in client.domains.list():
340
+ print(domain["name"]) # every domain, across all pages
341
+ ```
342
+
343
+ On the async client, `await` the call and iterate with `async for`:
344
+
345
+ ```python
346
+ async for domain in await client.domains.list():
347
+ print(domain["name"])
348
+ ```
349
+
350
+ ## Errors
351
+
352
+ A failed request raises an `AnypostError` subclass. Branch on `err.type`, the stable machine-readable code, not on the HTTP status.
353
+
354
+ ```python
355
+ from anypost import AnypostError, ValidationError, RateLimitError
356
+
357
+ try:
358
+ client.email.send({"from": from_, "to": to, "subject": subject, "html": html})
359
+ except ValidationError as err:
360
+ print(err.errors) # {"from": ["The from field is required."]}
361
+ except RateLimitError as err:
362
+ print(err.retry_after) # seconds, or None
363
+ except AnypostError as err:
364
+ print(err.type, err.status, err.message)
365
+ ```
366
+
367
+ | Class | `type` | Status |
368
+ |---|---|---|
369
+ | `ValidationError` | `validation_error` | `400`, `422` |
370
+ | `AuthenticationError` | `authentication_error` | `401` |
371
+ | `PermissionError` | `permission_error` | `403` |
372
+ | `NotFoundError` | `not_found` | `404` |
373
+ | `ConflictError` | `idempotency_concurrent`, `webhook_rotation_in_progress` | `409` |
374
+ | `IdempotencyMismatchError` | `idempotency_mismatch` | `422` |
375
+ | `RateLimitError` | `rate_limit_exceeded` | `429` |
376
+ | `PayloadTooLargeError` | `payload_too_large` | `413` |
377
+ | `APIError` | `internal_error`, `provisioning_error` | `5xx` |
378
+ | `APIConnectionError` | `connection_error` | none |
379
+
380
+ Every error carries `type`, `status`, `message`, `request_id`, and the parsed `raw` body.
381
+
382
+ ## Retries and idempotency
383
+
384
+ The client retries `429`, `502`, `503`, and network failures up to `max_retries` times (default 2), with exponential backoff and full jitter. It honors `Retry-After`.
385
+
386
+ Sends are made safe to retry automatically: when retries are enabled and you do not pass an `idempotency_key`, the client generates one and reuses it across attempts, so a retried send cannot deliver twice. Pass your own key to dedupe across process restarts:
387
+
388
+ ```python
389
+ client.email.send(message, idempotency_key=order_id)
390
+ ```
391
+
392
+ ## Configuration
393
+
394
+ ```python
395
+ Anypost(api_key="ap_your_api_key", base_url=..., timeout=..., max_retries=...)
396
+ ```
397
+
398
+ | Option | Default | Description |
399
+ |---|---|---|
400
+ | `api_key` | `ANYPOST_API_KEY` | Bearer credential (`ap_...`). |
401
+ | `base_url` | `https://api.anypost.com/v1` | API base URL. |
402
+ | `timeout` | `30.0` | Per-request timeout, in seconds. |
403
+ | `max_retries` | `2` | Automatic retries for transient failures. |
404
+ | `default_headers` | `{}` | Extra headers sent on every request. |
405
+ | `http_client` | a new one | Bring your own `httpx.Client` / `httpx.AsyncClient`. |
406
+
407
+ `AsyncAnypost` takes the same arguments. `send` and `send_batch` accept a per-call `idempotency_key`.
408
+
409
+ Close the client when you're done, or use it as a context manager:
410
+
411
+ ```python
412
+ with Anypost() as client:
413
+ client.email.send(message)
414
+
415
+ # async
416
+ async with AsyncAnypost() as client:
417
+ await client.email.send(message)
418
+ ```
419
+
420
+ ## License
421
+
422
+ MIT