couryo 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.
- couryo-0.1.0/.gitignore +13 -0
- couryo-0.1.0/LICENSE +21 -0
- couryo-0.1.0/PKG-INFO +309 -0
- couryo-0.1.0/README.md +275 -0
- couryo-0.1.0/README.pt-BR.md +275 -0
- couryo-0.1.0/pyproject.toml +55 -0
- couryo-0.1.0/src/couryo/__init__.py +48 -0
- couryo-0.1.0/src/couryo/_async_client.py +398 -0
- couryo-0.1.0/src/couryo/_base.py +223 -0
- couryo-0.1.0/src/couryo/_client.py +397 -0
- couryo-0.1.0/src/couryo/_version.py +1 -0
- couryo-0.1.0/src/couryo/django.py +249 -0
- couryo-0.1.0/src/couryo/errors.py +169 -0
- couryo-0.1.0/src/couryo/py.typed +0 -0
- couryo-0.1.0/src/couryo/types.py +611 -0
- couryo-0.1.0/src/couryo/webhooks.py +126 -0
- couryo-0.1.0/tests/fakes.py +79 -0
- couryo-0.1.0/tests/test_async.py +149 -0
- couryo-0.1.0/tests/test_client.py +343 -0
- couryo-0.1.0/tests/test_django.py +202 -0
- couryo-0.1.0/tests/test_webhooks.py +85 -0
- couryo-0.1.0/tests/webhook-vector.json +7 -0
couryo-0.1.0/.gitignore
ADDED
couryo-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wunka
|
|
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.
|
couryo-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: couryo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Couryo email API: typed, with retries, automatic Idempotency-Key, webhook verification and a Django email backend.
|
|
5
|
+
Project-URL: Homepage, https://couryo.com
|
|
6
|
+
Project-URL: Documentation, https://couryo.com/docs/sdks
|
|
7
|
+
Project-URL: Repository, https://github.com/Wunka-Tech/couryo-python
|
|
8
|
+
Project-URL: Issues, https://github.com/Wunka-Tech/couryo-python/issues
|
|
9
|
+
Author: Wunka
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: couryo,django,email,smtp,transactional email,webhooks
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Framework :: Django
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
26
|
+
Classifier: Topic :: Communications :: Email
|
|
27
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
28
|
+
Classifier: Typing :: Typed
|
|
29
|
+
Requires-Python: >=3.9
|
|
30
|
+
Requires-Dist: httpx<1,>=0.24
|
|
31
|
+
Provides-Extra: django
|
|
32
|
+
Requires-Dist: django>=3.2; extra == 'django'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# couryo (Python)
|
|
36
|
+
|
|
37
|
+
Official Python SDK for the [Couryo](https://couryo.com) email API. [Leia em português](https://github.com/Wunka-Tech/couryo-python/blob/main/README.pt-BR.md).
|
|
38
|
+
|
|
39
|
+
- Sync (`Couryo`) and async (`AsyncCouryo`) clients on top of `httpx`, the only dependency.
|
|
40
|
+
- Type hints from the API contract: `TypedDict`s in `couryo.types` for requests and responses (plain dicts at runtime).
|
|
41
|
+
- Automatic `Idempotency-Key` on every POST, reused on retries: a send that timed out but went through is never duplicated.
|
|
42
|
+
- Retries with backoff for network errors, `5xx` and `rate_limited` (honors `Retry-After`). Errors that would fail again (`domain_not_verified`, `daily_limit_reached`...) are raised right away.
|
|
43
|
+
- Webhook verification (Standard Webhooks) in one call.
|
|
44
|
+
- Django email backend (`couryo.django.EmailBackend`).
|
|
45
|
+
- Python 3.9 or newer.
|
|
46
|
+
|
|
47
|
+
> Not on PyPI yet. Inside the Couryo monorepo it lives in `packages/sdk-python` (`pip install -e packages/sdk-python`).
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install couryo
|
|
53
|
+
# with the Django backend
|
|
54
|
+
pip install "couryo[django]"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Send an email
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import os
|
|
61
|
+
from couryo import Couryo
|
|
62
|
+
|
|
63
|
+
couryo = Couryo(api_key=os.environ["COURYO_API_KEY"]) # ck_live_... or ck_test_... (COURYO_API_KEY is also read by default)
|
|
64
|
+
|
|
65
|
+
res = couryo.emails.send({
|
|
66
|
+
"from": "Shop <orders@example.com>",
|
|
67
|
+
"to": "ana@example.com",
|
|
68
|
+
"subject": "Order 1042 confirmed",
|
|
69
|
+
"html": "<p>Thanks!</p>",
|
|
70
|
+
"text": "Thanks!",
|
|
71
|
+
"tags": {"kind": "order"},
|
|
72
|
+
})
|
|
73
|
+
print(res["id"], res["status"])
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`Couryo` can be used as a context manager (`with Couryo() as couryo: ...`) or closed with `couryo.close()`.
|
|
77
|
+
|
|
78
|
+
### Async
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
import asyncio
|
|
82
|
+
from couryo import AsyncCouryo
|
|
83
|
+
|
|
84
|
+
async def main():
|
|
85
|
+
async with AsyncCouryo() as couryo:
|
|
86
|
+
res = await couryo.emails.send({"from": "Shop <orders@example.com>", "to": "ana@example.com", "subject": "Hi", "text": "Hello!"})
|
|
87
|
+
async for email in couryo.emails.iterate(status="bounced"):
|
|
88
|
+
print(email["id"])
|
|
89
|
+
|
|
90
|
+
asyncio.run(main())
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Outside `async with`, call `await couryo.aclose()` when you are done.
|
|
94
|
+
|
|
95
|
+
## Idempotency
|
|
96
|
+
|
|
97
|
+
Every POST gets an `Idempotency-Key` (a random UUID), reused on every retry. Pass your own key when the same business event may be sent twice:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
couryo.emails.send(body, idempotency_key="order-1042-confirmation")
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Saved templates
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
couryo.templates.create({"name": "welcome", "subject": "Welcome, {{name}}!", "html": "<h1>Hi, {{name}}</h1>"})
|
|
107
|
+
couryo.emails.send({"from": "Shop <hi@example.com>", "to": "ana@example.com", "template": "welcome", "variables": {"name": "Ana"}})
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Contacts, events and campaigns
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
# Create or update a contact (same email = merge attributes)
|
|
114
|
+
couryo.contacts.create({"email": "ana@example.com", "attributes": {"plan": "pro"}, "tags": ["customer"]})
|
|
115
|
+
|
|
116
|
+
# A contact event can start sequences
|
|
117
|
+
couryo.events.track(
|
|
118
|
+
{"contact": {"email": "ana@example.com"}, "name": "order.paid", "properties": {"total": 99.9}},
|
|
119
|
+
idempotency_key="order-1042-paid",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# A campaign to a segment
|
|
123
|
+
audience = {"type": "segment", "conditions": [{"field": "tag", "op": "eq", "value": "customer"}]}
|
|
124
|
+
print(couryo.contacts.count(audience))
|
|
125
|
+
campaign = couryo.campaigns.create({
|
|
126
|
+
"name": "October news",
|
|
127
|
+
"audience": audience,
|
|
128
|
+
"from": "Shop <news@example.com>",
|
|
129
|
+
"subject": "What is new in October",
|
|
130
|
+
"html": "<p>Hi!</p>",
|
|
131
|
+
})
|
|
132
|
+
check = couryo.campaigns.preflight(campaign["id"])
|
|
133
|
+
if check["can_send"]:
|
|
134
|
+
couryo.campaigns.schedule(campaign["id"], "2026-10-20T09:00:00-03:00") # or a datetime
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Errors
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from couryo import CouryoError, InvalidRequestError, RateLimitError
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
couryo.emails.send(body)
|
|
144
|
+
except InvalidRequestError as e:
|
|
145
|
+
print(e.code, e.param) # e.g. domain_not_verified, from
|
|
146
|
+
except CouryoError as e:
|
|
147
|
+
print(e.status, e.type, e.code, e.message, e.param, e.doc_url, e.request_id, e.retry_after)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Subclasses follow the error `type`: `InvalidRequestError`, `AuthenticationError`, `PermissionDeniedError`, `NotFoundError`, `ConflictError`, `RateLimitError`, `TierLimitError`, `APIError` and `NetworkError` (no response, `status` 0). `message` follows the `language` option (`pt-BR` by default, or `en`). A missing API key raises `ValueError` when the client is created.
|
|
151
|
+
|
|
152
|
+
## Webhooks
|
|
153
|
+
|
|
154
|
+
Use the raw body exactly as it arrived. `verify_webhook` returns the event (`{type, timestamp, data}`) or raises `WebhookVerificationError` for a wrong signature or a timestamp older than 5 minutes.
|
|
155
|
+
|
|
156
|
+
Flask:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
import os
|
|
160
|
+
from flask import Flask, request
|
|
161
|
+
from couryo import verify_webhook, WebhookVerificationError
|
|
162
|
+
|
|
163
|
+
app = Flask(__name__)
|
|
164
|
+
|
|
165
|
+
@app.post("/webhooks/couryo")
|
|
166
|
+
def couryo_webhook():
|
|
167
|
+
try:
|
|
168
|
+
event = verify_webhook(os.environ["COURYO_WEBHOOK_SECRET"], request.headers, request.get_data())
|
|
169
|
+
except WebhookVerificationError:
|
|
170
|
+
return "", 400
|
|
171
|
+
if event["type"] == "bounced":
|
|
172
|
+
... # event["data"]["email_id"], event["data"]["recipient"], event["data"]["explanation"]
|
|
173
|
+
return "", 200
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
FastAPI:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
import os
|
|
180
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
181
|
+
from couryo import verify_webhook, WebhookVerificationError
|
|
182
|
+
|
|
183
|
+
app = FastAPI()
|
|
184
|
+
|
|
185
|
+
@app.post("/webhooks/couryo")
|
|
186
|
+
async def couryo_webhook(request: Request):
|
|
187
|
+
try:
|
|
188
|
+
event = verify_webhook(os.environ["COURYO_WEBHOOK_SECRET"], request.headers, await request.body())
|
|
189
|
+
except WebhookVerificationError:
|
|
190
|
+
raise HTTPException(status_code=400)
|
|
191
|
+
return {"ok": True}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Django (`request.headers` or `request.META` both work):
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
from django.conf import settings
|
|
198
|
+
from django.http import HttpResponse, HttpResponseBadRequest
|
|
199
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
200
|
+
from django.views.decorators.http import require_POST
|
|
201
|
+
from couryo import verify_webhook, WebhookVerificationError
|
|
202
|
+
|
|
203
|
+
@csrf_exempt
|
|
204
|
+
@require_POST
|
|
205
|
+
def couryo_webhook(request):
|
|
206
|
+
try:
|
|
207
|
+
event = verify_webhook(settings.COURYO_WEBHOOK_SECRET, request.headers, request.body)
|
|
208
|
+
except WebhookVerificationError:
|
|
209
|
+
return HttpResponseBadRequest()
|
|
210
|
+
return HttpResponse(status=200)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Django email backend
|
|
214
|
+
|
|
215
|
+
On Django 6.1 or newer, configure it in `MAILERS` (the options `api_key`, `base_url`, `client` and `fail_silently` go in `OPTIONS`):
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
# settings.py
|
|
219
|
+
import os
|
|
220
|
+
|
|
221
|
+
MAILERS = {
|
|
222
|
+
"default": {
|
|
223
|
+
"BACKEND": "couryo.django.EmailBackend",
|
|
224
|
+
"OPTIONS": {"api_key": os.environ["COURYO_API_KEY"]}, # without it: the COURYO_API_KEY setting or environment variable
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
DEFAULT_FROM_EMAIL = "Shop <hi@example.com>"
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
With more than one entry in `MAILERS`, pick one with `send_mail(..., using="alias")` or `msg.send(using="alias")`.
|
|
231
|
+
|
|
232
|
+
On Django 6.0 or older, use `EMAIL_BACKEND` (Django 6.1 still accepts it with a deprecation warning, but not together with `MAILERS`):
|
|
233
|
+
|
|
234
|
+
```python
|
|
235
|
+
# settings.py
|
|
236
|
+
import os
|
|
237
|
+
|
|
238
|
+
EMAIL_BACKEND = "couryo.django.EmailBackend"
|
|
239
|
+
COURYO_API_KEY = os.environ["COURYO_API_KEY"] # falls back to the COURYO_API_KEY environment variable
|
|
240
|
+
# COURYO_BASE_URL = "https://api.couryo.com/v1" # optional
|
|
241
|
+
DEFAULT_FROM_EMAIL = "Shop <hi@example.com>"
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
`send_mail`, `EmailMessage` and `EmailMultiAlternatives` then go through the API: one `emails.send` per message, with `from_email`, `to`, `cc`, `bcc`, `reply_to`, `subject`, the body (text, or HTML when `content_subtype = "html"`), the `text/html` alternative and attachments (Base64; images with `Content-ID` become inline). Headers:
|
|
245
|
+
|
|
246
|
+
- `X-Couryo-Stream: marketing` (or `transactional`, the default);
|
|
247
|
+
- `X-Couryo-Tags: kind=order, store=downtown`;
|
|
248
|
+
- `Idempotency-Key: order-1042` (otherwise one is generated);
|
|
249
|
+
- other `X-*` headers are passed through.
|
|
250
|
+
|
|
251
|
+
The id of each sent message is stored in `message.couryo_id`. `fail_silently` is honored (on Django 6.1, as an `OPTIONS` entry) and `send_messages` returns how many messages were accepted.
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
from django.core.mail import EmailMultiAlternatives
|
|
255
|
+
|
|
256
|
+
msg = EmailMultiAlternatives("Order 1042 confirmed", "Thanks!", to=["ana@example.com"], headers={"X-Couryo-Tags": "kind=order"})
|
|
257
|
+
msg.attach_alternative("<p>Thanks!</p>", "text/html")
|
|
258
|
+
msg.send()
|
|
259
|
+
print(msg.couryo_id)
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Or SMTP, without the SDK
|
|
263
|
+
|
|
264
|
+
Django's SMTP backend also works with Couryo (same headers `X-Couryo-Stream` and `X-Couryo-Tags`). On Django 6.1 or newer:
|
|
265
|
+
|
|
266
|
+
```python
|
|
267
|
+
MAILERS = {
|
|
268
|
+
"default": {
|
|
269
|
+
"BACKEND": "django.core.mail.backends.smtp.EmailBackend",
|
|
270
|
+
"OPTIONS": {
|
|
271
|
+
"host": "smtp.couryo.com",
|
|
272
|
+
"port": 587, # STARTTLS (2525 also uses STARTTLS; 465 is TLS with "use_ssl": True instead of "use_tls")
|
|
273
|
+
"username": "couryo",
|
|
274
|
+
"password": os.environ["COURYO_API_KEY"], # key with the send or admin scope
|
|
275
|
+
"use_tls": True,
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
On Django 6.0 or older:
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
|
285
|
+
EMAIL_HOST = "smtp.couryo.com"
|
|
286
|
+
EMAIL_PORT = 587 # STARTTLS (2525 also uses STARTTLS; 465 is TLS with EMAIL_USE_SSL = True)
|
|
287
|
+
EMAIL_HOST_USER = "couryo"
|
|
288
|
+
EMAIL_HOST_PASSWORD = os.environ["COURYO_API_KEY"] # key with the send or admin scope
|
|
289
|
+
EMAIL_USE_TLS = True
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
## Everything else
|
|
293
|
+
|
|
294
|
+
| Resource | Methods |
|
|
295
|
+
|---|---|
|
|
296
|
+
| `emails` | `send`, `batch` (up to 100), `check`, `get`, `list`, `iterate` |
|
|
297
|
+
| `domains` | `list`, `create`, `get`, `delete`, `verify`, `health` (deliverability diagnosis, `refresh=True`) |
|
|
298
|
+
| `webhooks` | `list`, `create`, `get`, `update`, `delete` |
|
|
299
|
+
| `suppressions` | `list`, `add(email, stream="all")`, `remove` |
|
|
300
|
+
| `events` | `list`, `iterate` (delivery events), `track` (contact event) |
|
|
301
|
+
| `templates` | `list`, `create`, `get`, `update`, `delete`, `versions`, `preview(id_or_name, variables)` |
|
|
302
|
+
| `contacts` | `create` (upsert), `list`, `get` (id or email), `update`, `delete`, `iterate`, `count(audience)` |
|
|
303
|
+
| `campaigns` | `create`, `list`, `get`, `update`, `delete`, `preflight`, `send`, `schedule`, `pause`, `resume`, `cancel`, `test(id, to=None)`, `stats` |
|
|
304
|
+
|
|
305
|
+
Every POST method accepts `idempotency_key=`. `iterate` walks every page: `for email in couryo.emails.iterate(status="bounced"): ...` (`async for` in `AsyncCouryo`).
|
|
306
|
+
|
|
307
|
+
Options: `api_key`, `base_url`, `max_retries` (2), `timeout` (30 s per attempt), `language`, `http_client` (your own `httpx.Client` or `httpx.AsyncClient`, which the SDK does not close).
|
|
308
|
+
|
|
309
|
+
Docs: https://couryo.com/en/docs
|
couryo-0.1.0/README.md
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# couryo (Python)
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the [Couryo](https://couryo.com) email API. [Leia em português](https://github.com/Wunka-Tech/couryo-python/blob/main/README.pt-BR.md).
|
|
4
|
+
|
|
5
|
+
- Sync (`Couryo`) and async (`AsyncCouryo`) clients on top of `httpx`, the only dependency.
|
|
6
|
+
- Type hints from the API contract: `TypedDict`s in `couryo.types` for requests and responses (plain dicts at runtime).
|
|
7
|
+
- Automatic `Idempotency-Key` on every POST, reused on retries: a send that timed out but went through is never duplicated.
|
|
8
|
+
- Retries with backoff for network errors, `5xx` and `rate_limited` (honors `Retry-After`). Errors that would fail again (`domain_not_verified`, `daily_limit_reached`...) are raised right away.
|
|
9
|
+
- Webhook verification (Standard Webhooks) in one call.
|
|
10
|
+
- Django email backend (`couryo.django.EmailBackend`).
|
|
11
|
+
- Python 3.9 or newer.
|
|
12
|
+
|
|
13
|
+
> Not on PyPI yet. Inside the Couryo monorepo it lives in `packages/sdk-python` (`pip install -e packages/sdk-python`).
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install couryo
|
|
19
|
+
# with the Django backend
|
|
20
|
+
pip install "couryo[django]"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Send an email
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import os
|
|
27
|
+
from couryo import Couryo
|
|
28
|
+
|
|
29
|
+
couryo = Couryo(api_key=os.environ["COURYO_API_KEY"]) # ck_live_... or ck_test_... (COURYO_API_KEY is also read by default)
|
|
30
|
+
|
|
31
|
+
res = couryo.emails.send({
|
|
32
|
+
"from": "Shop <orders@example.com>",
|
|
33
|
+
"to": "ana@example.com",
|
|
34
|
+
"subject": "Order 1042 confirmed",
|
|
35
|
+
"html": "<p>Thanks!</p>",
|
|
36
|
+
"text": "Thanks!",
|
|
37
|
+
"tags": {"kind": "order"},
|
|
38
|
+
})
|
|
39
|
+
print(res["id"], res["status"])
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`Couryo` can be used as a context manager (`with Couryo() as couryo: ...`) or closed with `couryo.close()`.
|
|
43
|
+
|
|
44
|
+
### Async
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import asyncio
|
|
48
|
+
from couryo import AsyncCouryo
|
|
49
|
+
|
|
50
|
+
async def main():
|
|
51
|
+
async with AsyncCouryo() as couryo:
|
|
52
|
+
res = await couryo.emails.send({"from": "Shop <orders@example.com>", "to": "ana@example.com", "subject": "Hi", "text": "Hello!"})
|
|
53
|
+
async for email in couryo.emails.iterate(status="bounced"):
|
|
54
|
+
print(email["id"])
|
|
55
|
+
|
|
56
|
+
asyncio.run(main())
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Outside `async with`, call `await couryo.aclose()` when you are done.
|
|
60
|
+
|
|
61
|
+
## Idempotency
|
|
62
|
+
|
|
63
|
+
Every POST gets an `Idempotency-Key` (a random UUID), reused on every retry. Pass your own key when the same business event may be sent twice:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
couryo.emails.send(body, idempotency_key="order-1042-confirmation")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Saved templates
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
couryo.templates.create({"name": "welcome", "subject": "Welcome, {{name}}!", "html": "<h1>Hi, {{name}}</h1>"})
|
|
73
|
+
couryo.emails.send({"from": "Shop <hi@example.com>", "to": "ana@example.com", "template": "welcome", "variables": {"name": "Ana"}})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Contacts, events and campaigns
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
# Create or update a contact (same email = merge attributes)
|
|
80
|
+
couryo.contacts.create({"email": "ana@example.com", "attributes": {"plan": "pro"}, "tags": ["customer"]})
|
|
81
|
+
|
|
82
|
+
# A contact event can start sequences
|
|
83
|
+
couryo.events.track(
|
|
84
|
+
{"contact": {"email": "ana@example.com"}, "name": "order.paid", "properties": {"total": 99.9}},
|
|
85
|
+
idempotency_key="order-1042-paid",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# A campaign to a segment
|
|
89
|
+
audience = {"type": "segment", "conditions": [{"field": "tag", "op": "eq", "value": "customer"}]}
|
|
90
|
+
print(couryo.contacts.count(audience))
|
|
91
|
+
campaign = couryo.campaigns.create({
|
|
92
|
+
"name": "October news",
|
|
93
|
+
"audience": audience,
|
|
94
|
+
"from": "Shop <news@example.com>",
|
|
95
|
+
"subject": "What is new in October",
|
|
96
|
+
"html": "<p>Hi!</p>",
|
|
97
|
+
})
|
|
98
|
+
check = couryo.campaigns.preflight(campaign["id"])
|
|
99
|
+
if check["can_send"]:
|
|
100
|
+
couryo.campaigns.schedule(campaign["id"], "2026-10-20T09:00:00-03:00") # or a datetime
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Errors
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from couryo import CouryoError, InvalidRequestError, RateLimitError
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
couryo.emails.send(body)
|
|
110
|
+
except InvalidRequestError as e:
|
|
111
|
+
print(e.code, e.param) # e.g. domain_not_verified, from
|
|
112
|
+
except CouryoError as e:
|
|
113
|
+
print(e.status, e.type, e.code, e.message, e.param, e.doc_url, e.request_id, e.retry_after)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Subclasses follow the error `type`: `InvalidRequestError`, `AuthenticationError`, `PermissionDeniedError`, `NotFoundError`, `ConflictError`, `RateLimitError`, `TierLimitError`, `APIError` and `NetworkError` (no response, `status` 0). `message` follows the `language` option (`pt-BR` by default, or `en`). A missing API key raises `ValueError` when the client is created.
|
|
117
|
+
|
|
118
|
+
## Webhooks
|
|
119
|
+
|
|
120
|
+
Use the raw body exactly as it arrived. `verify_webhook` returns the event (`{type, timestamp, data}`) or raises `WebhookVerificationError` for a wrong signature or a timestamp older than 5 minutes.
|
|
121
|
+
|
|
122
|
+
Flask:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
import os
|
|
126
|
+
from flask import Flask, request
|
|
127
|
+
from couryo import verify_webhook, WebhookVerificationError
|
|
128
|
+
|
|
129
|
+
app = Flask(__name__)
|
|
130
|
+
|
|
131
|
+
@app.post("/webhooks/couryo")
|
|
132
|
+
def couryo_webhook():
|
|
133
|
+
try:
|
|
134
|
+
event = verify_webhook(os.environ["COURYO_WEBHOOK_SECRET"], request.headers, request.get_data())
|
|
135
|
+
except WebhookVerificationError:
|
|
136
|
+
return "", 400
|
|
137
|
+
if event["type"] == "bounced":
|
|
138
|
+
... # event["data"]["email_id"], event["data"]["recipient"], event["data"]["explanation"]
|
|
139
|
+
return "", 200
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
FastAPI:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
import os
|
|
146
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
147
|
+
from couryo import verify_webhook, WebhookVerificationError
|
|
148
|
+
|
|
149
|
+
app = FastAPI()
|
|
150
|
+
|
|
151
|
+
@app.post("/webhooks/couryo")
|
|
152
|
+
async def couryo_webhook(request: Request):
|
|
153
|
+
try:
|
|
154
|
+
event = verify_webhook(os.environ["COURYO_WEBHOOK_SECRET"], request.headers, await request.body())
|
|
155
|
+
except WebhookVerificationError:
|
|
156
|
+
raise HTTPException(status_code=400)
|
|
157
|
+
return {"ok": True}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Django (`request.headers` or `request.META` both work):
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
from django.conf import settings
|
|
164
|
+
from django.http import HttpResponse, HttpResponseBadRequest
|
|
165
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
166
|
+
from django.views.decorators.http import require_POST
|
|
167
|
+
from couryo import verify_webhook, WebhookVerificationError
|
|
168
|
+
|
|
169
|
+
@csrf_exempt
|
|
170
|
+
@require_POST
|
|
171
|
+
def couryo_webhook(request):
|
|
172
|
+
try:
|
|
173
|
+
event = verify_webhook(settings.COURYO_WEBHOOK_SECRET, request.headers, request.body)
|
|
174
|
+
except WebhookVerificationError:
|
|
175
|
+
return HttpResponseBadRequest()
|
|
176
|
+
return HttpResponse(status=200)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Django email backend
|
|
180
|
+
|
|
181
|
+
On Django 6.1 or newer, configure it in `MAILERS` (the options `api_key`, `base_url`, `client` and `fail_silently` go in `OPTIONS`):
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
# settings.py
|
|
185
|
+
import os
|
|
186
|
+
|
|
187
|
+
MAILERS = {
|
|
188
|
+
"default": {
|
|
189
|
+
"BACKEND": "couryo.django.EmailBackend",
|
|
190
|
+
"OPTIONS": {"api_key": os.environ["COURYO_API_KEY"]}, # without it: the COURYO_API_KEY setting or environment variable
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
DEFAULT_FROM_EMAIL = "Shop <hi@example.com>"
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
With more than one entry in `MAILERS`, pick one with `send_mail(..., using="alias")` or `msg.send(using="alias")`.
|
|
197
|
+
|
|
198
|
+
On Django 6.0 or older, use `EMAIL_BACKEND` (Django 6.1 still accepts it with a deprecation warning, but not together with `MAILERS`):
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
# settings.py
|
|
202
|
+
import os
|
|
203
|
+
|
|
204
|
+
EMAIL_BACKEND = "couryo.django.EmailBackend"
|
|
205
|
+
COURYO_API_KEY = os.environ["COURYO_API_KEY"] # falls back to the COURYO_API_KEY environment variable
|
|
206
|
+
# COURYO_BASE_URL = "https://api.couryo.com/v1" # optional
|
|
207
|
+
DEFAULT_FROM_EMAIL = "Shop <hi@example.com>"
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`send_mail`, `EmailMessage` and `EmailMultiAlternatives` then go through the API: one `emails.send` per message, with `from_email`, `to`, `cc`, `bcc`, `reply_to`, `subject`, the body (text, or HTML when `content_subtype = "html"`), the `text/html` alternative and attachments (Base64; images with `Content-ID` become inline). Headers:
|
|
211
|
+
|
|
212
|
+
- `X-Couryo-Stream: marketing` (or `transactional`, the default);
|
|
213
|
+
- `X-Couryo-Tags: kind=order, store=downtown`;
|
|
214
|
+
- `Idempotency-Key: order-1042` (otherwise one is generated);
|
|
215
|
+
- other `X-*` headers are passed through.
|
|
216
|
+
|
|
217
|
+
The id of each sent message is stored in `message.couryo_id`. `fail_silently` is honored (on Django 6.1, as an `OPTIONS` entry) and `send_messages` returns how many messages were accepted.
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
from django.core.mail import EmailMultiAlternatives
|
|
221
|
+
|
|
222
|
+
msg = EmailMultiAlternatives("Order 1042 confirmed", "Thanks!", to=["ana@example.com"], headers={"X-Couryo-Tags": "kind=order"})
|
|
223
|
+
msg.attach_alternative("<p>Thanks!</p>", "text/html")
|
|
224
|
+
msg.send()
|
|
225
|
+
print(msg.couryo_id)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Or SMTP, without the SDK
|
|
229
|
+
|
|
230
|
+
Django's SMTP backend also works with Couryo (same headers `X-Couryo-Stream` and `X-Couryo-Tags`). On Django 6.1 or newer:
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
MAILERS = {
|
|
234
|
+
"default": {
|
|
235
|
+
"BACKEND": "django.core.mail.backends.smtp.EmailBackend",
|
|
236
|
+
"OPTIONS": {
|
|
237
|
+
"host": "smtp.couryo.com",
|
|
238
|
+
"port": 587, # STARTTLS (2525 also uses STARTTLS; 465 is TLS with "use_ssl": True instead of "use_tls")
|
|
239
|
+
"username": "couryo",
|
|
240
|
+
"password": os.environ["COURYO_API_KEY"], # key with the send or admin scope
|
|
241
|
+
"use_tls": True,
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
On Django 6.0 or older:
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
|
251
|
+
EMAIL_HOST = "smtp.couryo.com"
|
|
252
|
+
EMAIL_PORT = 587 # STARTTLS (2525 also uses STARTTLS; 465 is TLS with EMAIL_USE_SSL = True)
|
|
253
|
+
EMAIL_HOST_USER = "couryo"
|
|
254
|
+
EMAIL_HOST_PASSWORD = os.environ["COURYO_API_KEY"] # key with the send or admin scope
|
|
255
|
+
EMAIL_USE_TLS = True
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
## Everything else
|
|
259
|
+
|
|
260
|
+
| Resource | Methods |
|
|
261
|
+
|---|---|
|
|
262
|
+
| `emails` | `send`, `batch` (up to 100), `check`, `get`, `list`, `iterate` |
|
|
263
|
+
| `domains` | `list`, `create`, `get`, `delete`, `verify`, `health` (deliverability diagnosis, `refresh=True`) |
|
|
264
|
+
| `webhooks` | `list`, `create`, `get`, `update`, `delete` |
|
|
265
|
+
| `suppressions` | `list`, `add(email, stream="all")`, `remove` |
|
|
266
|
+
| `events` | `list`, `iterate` (delivery events), `track` (contact event) |
|
|
267
|
+
| `templates` | `list`, `create`, `get`, `update`, `delete`, `versions`, `preview(id_or_name, variables)` |
|
|
268
|
+
| `contacts` | `create` (upsert), `list`, `get` (id or email), `update`, `delete`, `iterate`, `count(audience)` |
|
|
269
|
+
| `campaigns` | `create`, `list`, `get`, `update`, `delete`, `preflight`, `send`, `schedule`, `pause`, `resume`, `cancel`, `test(id, to=None)`, `stats` |
|
|
270
|
+
|
|
271
|
+
Every POST method accepts `idempotency_key=`. `iterate` walks every page: `for email in couryo.emails.iterate(status="bounced"): ...` (`async for` in `AsyncCouryo`).
|
|
272
|
+
|
|
273
|
+
Options: `api_key`, `base_url`, `max_retries` (2), `timeout` (30 s per attempt), `language`, `http_client` (your own `httpx.Client` or `httpx.AsyncClient`, which the SDK does not close).
|
|
274
|
+
|
|
275
|
+
Docs: https://couryo.com/en/docs
|