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.
- anypost-1.0.0/.gitignore +17 -0
- anypost-1.0.0/LICENSE +21 -0
- anypost-1.0.0/PKG-INFO +422 -0
- anypost-1.0.0/README.md +390 -0
- anypost-1.0.0/pyproject.toml +77 -0
- anypost-1.0.0/src/anypost/__init__.py +75 -0
- anypost-1.0.0/src/anypost/_async/__init__.py +7 -0
- anypost-1.0.0/src/anypost/_async/client.py +109 -0
- anypost-1.0.0/src/anypost/_async/resources.py +346 -0
- anypost-1.0.0/src/anypost/_async_http.py +96 -0
- anypost-1.0.0/src/anypost/_base64.py +10 -0
- anypost-1.0.0/src/anypost/_client.py +110 -0
- anypost-1.0.0/src/anypost/_exceptions.py +234 -0
- anypost-1.0.0/src/anypost/_http.py +176 -0
- anypost-1.0.0/src/anypost/_pagination.py +92 -0
- anypost-1.0.0/src/anypost/_version.py +3 -0
- anypost-1.0.0/src/anypost/_webhook_signature.py +171 -0
- anypost-1.0.0/src/anypost/py.typed +0 -0
- anypost-1.0.0/src/anypost/resources/__init__.py +23 -0
- anypost-1.0.0/src/anypost/resources/api_keys.py +62 -0
- anypost-1.0.0/src/anypost/resources/domains.py +62 -0
- anypost-1.0.0/src/anypost/resources/email.py +76 -0
- anypost-1.0.0/src/anypost/resources/events.py +53 -0
- anypost-1.0.0/src/anypost/resources/identity.py +17 -0
- anypost-1.0.0/src/anypost/resources/suppressions.py +86 -0
- anypost-1.0.0/src/anypost/resources/templates.py +86 -0
- anypost-1.0.0/src/anypost/resources/webhooks.py +82 -0
- anypost-1.0.0/src/anypost/types/__init__.py +117 -0
- anypost-1.0.0/src/anypost/types/api_key.py +56 -0
- anypost-1.0.0/src/anypost/types/common.py +55 -0
- anypost-1.0.0/src/anypost/types/domain.py +87 -0
- anypost-1.0.0/src/anypost/types/email.py +135 -0
- anypost-1.0.0/src/anypost/types/event.py +99 -0
- anypost-1.0.0/src/anypost/types/identity.py +27 -0
- anypost-1.0.0/src/anypost/types/suppression.py +62 -0
- anypost-1.0.0/src/anypost/types/template.py +88 -0
- anypost-1.0.0/src/anypost/types/webhook.py +82 -0
anypost-1.0.0/.gitignore
ADDED
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
|