sendclipper 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.
- sendclipper-0.1.0/.gitignore +9 -0
- sendclipper-0.1.0/CHANGELOG.md +12 -0
- sendclipper-0.1.0/LICENSE +21 -0
- sendclipper-0.1.0/PKG-INFO +233 -0
- sendclipper-0.1.0/README.md +204 -0
- sendclipper-0.1.0/pyproject.toml +55 -0
- sendclipper-0.1.0/src/sendclipper/__init__.py +65 -0
- sendclipper-0.1.0/src/sendclipper/_client.py +81 -0
- sendclipper-0.1.0/src/sendclipper/_core.py +454 -0
- sendclipper-0.1.0/src/sendclipper/_errors.py +118 -0
- sendclipper-0.1.0/src/sendclipper/_version.py +3 -0
- sendclipper-0.1.0/src/sendclipper/_webhooks.py +147 -0
- sendclipper-0.1.0/src/sendclipper/py.typed +0 -0
- sendclipper-0.1.0/src/sendclipper/resources/__init__.py +1 -0
- sendclipper-0.1.0/src/sendclipper/resources/_base.py +15 -0
- sendclipper-0.1.0/src/sendclipper/resources/audience.py +119 -0
- sendclipper-0.1.0/src/sendclipper/resources/domains.py +47 -0
- sendclipper-0.1.0/src/sendclipper/resources/emails.py +68 -0
- sendclipper-0.1.0/src/sendclipper/resources/mailboxes.py +89 -0
- sendclipper-0.1.0/src/sendclipper/resources/sandboxes.py +137 -0
- sendclipper-0.1.0/src/sendclipper/resources/templates.py +115 -0
- sendclipper-0.1.0/src/sendclipper/resources/webhooks.py +105 -0
- sendclipper-0.1.0/src/sendclipper/resources/workspace.py +30 -0
- sendclipper-0.1.0/src/sendclipper/types.py +875 -0
- sendclipper-0.1.0/tests/__init__.py +0 -0
- sendclipper-0.1.0/tests/conftest.py +81 -0
- sendclipper-0.1.0/tests/test_client.py +499 -0
- sendclipper-0.1.0/tests/test_webhooks.py +108 -0
- sendclipper-0.1.0/tests/wheel/smoke.py +35 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-10-16
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- `emails.send`, `batch.send`, `emails.get`, `emails.list`, `emails.iterate`, `emails.stats`.
|
|
8
|
+
- Templates (with tags and image assets), domains, sandboxes (including the JSON door and `wait_for`), webhooks and their dispatches, contacts, audiences, suppressions, mailboxes, automations, workspace and API keys.
|
|
9
|
+
- Every method returns a `Result` with `data`, `error` and `headers`; it unpacks to `(data, error)`.
|
|
10
|
+
- Automatic `Idempotency-Key` on every JSON write; retries on network errors, 429 and 5xx.
|
|
11
|
+
- `verify_webhook()` for `X-SendClipper-Signature-V2`, rotation-aware.
|
|
12
|
+
- No runtime dependencies; typed (`py.typed`), Python 3.9 or newer.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SendClipper
|
|
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.
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sendclipper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the SendClipper email API: send mail, manage domains, templates, sandboxes, webhooks, contacts and suppressions.
|
|
5
|
+
Project-URL: Homepage, https://sendclipper.com
|
|
6
|
+
Project-URL: Documentation, https://sendclipper.com/docs#sdk-python
|
|
7
|
+
Project-URL: Source, https://github.com/sendclipper/sendclipper-python
|
|
8
|
+
Project-URL: Issues, https://github.com/sendclipper/sendclipper-python/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/sendclipper/sendclipper-python/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: SendClipper <support@sendclipper.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: email,sdk,sendclipper,smtp,transactional-email,webhooks
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
+
Classifier: Topic :: Communications :: Email
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.9
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# SendClipper Python SDK
|
|
31
|
+
|
|
32
|
+
The official Python library for the [SendClipper](https://sendclipper.com) email API.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install sendclipper
|
|
36
|
+
# or
|
|
37
|
+
uv add sendclipper
|
|
38
|
+
# or
|
|
39
|
+
poetry add sendclipper
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Python 3.9 or newer. No runtime dependencies. Fully typed.
|
|
43
|
+
|
|
44
|
+
## Setup
|
|
45
|
+
|
|
46
|
+
Create an API key in the dashboard under **API keys**.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from sendclipper import SendClipper
|
|
50
|
+
|
|
51
|
+
sc = SendClipper("sc_live_xxxxxxxxxxxxxxxx")
|
|
52
|
+
# or: SendClipper() reads SENDCLIPPER_API_KEY
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Send an email
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
data, error = sc.emails.send({
|
|
59
|
+
"from": "hello@send.acme.io",
|
|
60
|
+
"to": "ada@lumen.io",
|
|
61
|
+
"subject": "Your receipt",
|
|
62
|
+
"html": "<p>Thanks for your order.</p>",
|
|
63
|
+
"text": "Thanks for your order.",
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
if error:
|
|
67
|
+
print(error.name, error.message) # e.g. domain_not_verified
|
|
68
|
+
else:
|
|
69
|
+
print(f"Email {data['id']} accepted")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Parameters and responses are plain dicts whose keys are the API's own field names (`templateId`, `sendClass`, ...), exactly as the [API reference](https://sendclipper.com/docs) writes them. Your editor still completes them: every one is a `TypedDict` in `sendclipper.types`.
|
|
73
|
+
|
|
74
|
+
`from` must be a bare address on a domain you verified in SendClipper. Each message has one recipient in `to`; for several, use `batch.send`.
|
|
75
|
+
|
|
76
|
+
## Send with a template
|
|
77
|
+
|
|
78
|
+
Design and publish a template in the dashboard, then send it by id or by its send name:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
sc.emails.send({
|
|
82
|
+
"from": "hello@send.acme.io",
|
|
83
|
+
"to": "ada@lumen.io",
|
|
84
|
+
"templateId": "tpl_9f2b7c10ad42", # or "template": "welcome"
|
|
85
|
+
"variables": {"first_name": "Ada"},
|
|
86
|
+
})
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
A tag with no value and no `{{tag|fallback}}` is refused by name (`template_variables_missing`), never sent blank.
|
|
90
|
+
|
|
91
|
+
## Send a batch
|
|
92
|
+
|
|
93
|
+
Up to 500 messages in one request. Each item is accepted or refused on its own:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
data, error = sc.batch.send([
|
|
97
|
+
{"from": "hello@send.acme.io", "to": "ada@lumen.io", "subject": "Hi Ada", "html": "<p>Hi</p>"},
|
|
98
|
+
{"from": "hello@send.acme.io", "to": "alan@lumen.io", "templateId": "tpl_9f2b7c10ad42", "variables": {"first_name": "Alan"}},
|
|
99
|
+
])
|
|
100
|
+
|
|
101
|
+
for item in data["data"]:
|
|
102
|
+
if item["status"] == "failed":
|
|
103
|
+
print(item["index"], item["error"]["code"])
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Errors
|
|
107
|
+
|
|
108
|
+
API methods never raise for an API refusal or a network failure. They return a `Result` with `data`, `error` and `headers`, and exactly one of `data` and `error` is set. A `Result` unpacks to `(data, error)`:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
result = sc.domains.list()
|
|
112
|
+
result.data # the parsed response, or None
|
|
113
|
+
result.error # an ErrorResponse, or None
|
|
114
|
+
result.headers # response headers (lower-case names), or None when no answer arrived
|
|
115
|
+
result.ok # error is None
|
|
116
|
+
|
|
117
|
+
domains = sc.domains.list().unwrap() # data, or raises SendClipperApiError
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class ErrorResponse:
|
|
123
|
+
name: str # SendClipper's code: "validation_failed", "rate_limited", "network_error", ...
|
|
124
|
+
message: str # a sentence for a person
|
|
125
|
+
status_code: Optional[int] # None when no answer arrived (network, timeout)
|
|
126
|
+
detail: Any = None # code-specific facts
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The iterators (`emails.iterate()` and friends) raise `SendClipperApiError` instead, since a loop has no `error` to hand back. `err.code` is the API's code and `err.status` the HTTP status.
|
|
130
|
+
|
|
131
|
+
## Retries and idempotency
|
|
132
|
+
|
|
133
|
+
Network errors, `429` and `5xx` answers are retried twice with backoff, honouring `Retry-After`. Every JSON write carries an `Idempotency-Key`, and the API answers a repeated key with the first result for 24 hours. A retried send therefore never goes out twice.
|
|
134
|
+
|
|
135
|
+
To make your own retries safe as well (a job that runs again after a crash), pass a key that names the logical operation:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
sc.emails.send(params, idempotency_key=f"order-{order.id}-receipt")
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Request options
|
|
142
|
+
|
|
143
|
+
Every method takes request options as keyword arguments after its own parameters:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
sc.emails.list({"status": "bounced"}, timeout=5, headers={"X-Trace-Id": trace_id})
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
| Option | Meaning |
|
|
150
|
+
|---|---|
|
|
151
|
+
| `idempotency_key` | Your own key for a write |
|
|
152
|
+
| `timeout` | Per-attempt timeout in seconds (default 30) |
|
|
153
|
+
| `max_retries` | Retries for this call (default 2) |
|
|
154
|
+
| `headers` | Extra request headers |
|
|
155
|
+
|
|
156
|
+
Client options: `SendClipper(api_key, base_url=..., timeout=..., max_retries=..., default_headers=..., transport=...)`. `base_url` also reads `SENDCLIPPER_BASE_URL`. `transport` replaces the standard-library HTTP sender, e.g. to go through a proxy library: it receives a `TransportRequest` and returns a `TransportResponse`.
|
|
157
|
+
|
|
158
|
+
## Verify webhooks
|
|
159
|
+
|
|
160
|
+
SendClipper signs each delivery. Verify it against the **raw** body, before parsing:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
import os
|
|
164
|
+
from flask import Flask, request
|
|
165
|
+
from sendclipper import verify_webhook, WebhookVerificationError
|
|
166
|
+
|
|
167
|
+
app = Flask(__name__)
|
|
168
|
+
|
|
169
|
+
@app.post("/hooks/sendclipper")
|
|
170
|
+
def sendclipper_hook():
|
|
171
|
+
try:
|
|
172
|
+
event = verify_webhook(
|
|
173
|
+
request.get_data(), # raw bytes, not request.json
|
|
174
|
+
request.headers,
|
|
175
|
+
os.environ["SENDCLIPPER_WEBHOOK_SECRET"],
|
|
176
|
+
)
|
|
177
|
+
except WebhookVerificationError as err:
|
|
178
|
+
return err.reason, 400
|
|
179
|
+
# event["id"] is stable across retries: deduplicate on it
|
|
180
|
+
print(event["event"], event["data"]["to"])
|
|
181
|
+
return "", 200
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The same call works in Django (`request.body`, `request.headers`) and FastAPI (`await request.body()`, `request.headers`). During a secret rotation a delivery carries two signatures and either one is accepted. A delivery signed more than five minutes away from now is refused. `sc.webhooks.verify(...)` is the same function.
|
|
185
|
+
|
|
186
|
+
To test your handler locally, `signature_header(secret, raw_body)` builds a valid header.
|
|
187
|
+
|
|
188
|
+
## Sandboxes
|
|
189
|
+
|
|
190
|
+
A sandbox traps mail while you develop: nothing is delivered.
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
box = sc.sandboxes.create({"name": "my-app-dev"}).unwrap()
|
|
194
|
+
creds = sc.sandboxes.credentials(box["id"]).unwrap() # SMTP host, port, username, password
|
|
195
|
+
|
|
196
|
+
# Or send through the sandbox's JSON door with its own token, using the same fields as emails.send():
|
|
197
|
+
token = sc.sandboxes.reset_credentials(box["id"]).unwrap()
|
|
198
|
+
sc.sandboxes.send(token["apiToken"], {"from": "app@example.com", "to": "test@example.com", "subject": "Hi", "html": "<p>Hi</p>"})
|
|
199
|
+
|
|
200
|
+
# In a test: wait for the message to arrive
|
|
201
|
+
msg, error = sc.sandboxes.messages.wait_for(box["id"], match=lambda m: m["subject"] == "Hi")
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Everything else
|
|
205
|
+
|
|
206
|
+
| Resource | Methods |
|
|
207
|
+
|---|---|
|
|
208
|
+
| `emails` | `send`, `get`, `list`, `iterate`, `stats` |
|
|
209
|
+
| `batch` | `send` |
|
|
210
|
+
| `domains` | `list`, `create`, `records`, `verify`, `enable_receiving`, `disable_receiving`, `set_region`, `deletion_preview`, `remove` |
|
|
211
|
+
| `templates` | `list`, `get`, `html`, `create`, `update`, `remove`, `duplicate`, `publish`, `unpublish`, `preview`, `set_tags`, `projects` |
|
|
212
|
+
| `templates.tags` | `list`, `create`, `update`, `remove` |
|
|
213
|
+
| `templates.assets` | `list`, `upload` |
|
|
214
|
+
| `sandboxes` | `list`, `get`, `create`, `update`, `remove`, `credentials`, `reset_credentials`, `send` |
|
|
215
|
+
| `sandboxes.messages` | `list`, `get`, `raw`, `remove`, `clear`, `mark_all_read`, `wait_for` |
|
|
216
|
+
| `webhooks` | `list`, `create`, `update`, `remove`, `rotate_secret`, `deliveries`, `verify` |
|
|
217
|
+
| `webhooks.dispatches` | `list`, `iterate`, `get`, `replay`, `replay_all` |
|
|
218
|
+
| `contacts` | `list`, `iterate`, `create`, `import_`, `import_preview`, `export` |
|
|
219
|
+
| `audiences` | `list` |
|
|
220
|
+
| `suppressions` | `list`, `iterate`, `create`, `remove`, `export` |
|
|
221
|
+
| `mailboxes` | `list`, `get`, `create`, `update`, `archive` |
|
|
222
|
+
| `mailboxes.messages` | `list`, `iterate`, `get`, `attachment` |
|
|
223
|
+
| `automations` | `enroll` |
|
|
224
|
+
| `workspace` | `get` |
|
|
225
|
+
| `api_keys` | `list` |
|
|
226
|
+
|
|
227
|
+
`contacts.import_` has a trailing underscore because `import` is a Python keyword. Management routes need a key with **Full access**. A sending-only key can call `emails.send` and `batch.send`.
|
|
228
|
+
|
|
229
|
+
Full API reference: <https://sendclipper.com/docs>.
|
|
230
|
+
|
|
231
|
+
## License
|
|
232
|
+
|
|
233
|
+
MIT
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# SendClipper Python SDK
|
|
2
|
+
|
|
3
|
+
The official Python library for the [SendClipper](https://sendclipper.com) email API.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install sendclipper
|
|
7
|
+
# or
|
|
8
|
+
uv add sendclipper
|
|
9
|
+
# or
|
|
10
|
+
poetry add sendclipper
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Python 3.9 or newer. No runtime dependencies. Fully typed.
|
|
14
|
+
|
|
15
|
+
## Setup
|
|
16
|
+
|
|
17
|
+
Create an API key in the dashboard under **API keys**.
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from sendclipper import SendClipper
|
|
21
|
+
|
|
22
|
+
sc = SendClipper("sc_live_xxxxxxxxxxxxxxxx")
|
|
23
|
+
# or: SendClipper() reads SENDCLIPPER_API_KEY
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Send an email
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
data, error = sc.emails.send({
|
|
30
|
+
"from": "hello@send.acme.io",
|
|
31
|
+
"to": "ada@lumen.io",
|
|
32
|
+
"subject": "Your receipt",
|
|
33
|
+
"html": "<p>Thanks for your order.</p>",
|
|
34
|
+
"text": "Thanks for your order.",
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
if error:
|
|
38
|
+
print(error.name, error.message) # e.g. domain_not_verified
|
|
39
|
+
else:
|
|
40
|
+
print(f"Email {data['id']} accepted")
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Parameters and responses are plain dicts whose keys are the API's own field names (`templateId`, `sendClass`, ...), exactly as the [API reference](https://sendclipper.com/docs) writes them. Your editor still completes them: every one is a `TypedDict` in `sendclipper.types`.
|
|
44
|
+
|
|
45
|
+
`from` must be a bare address on a domain you verified in SendClipper. Each message has one recipient in `to`; for several, use `batch.send`.
|
|
46
|
+
|
|
47
|
+
## Send with a template
|
|
48
|
+
|
|
49
|
+
Design and publish a template in the dashboard, then send it by id or by its send name:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
sc.emails.send({
|
|
53
|
+
"from": "hello@send.acme.io",
|
|
54
|
+
"to": "ada@lumen.io",
|
|
55
|
+
"templateId": "tpl_9f2b7c10ad42", # or "template": "welcome"
|
|
56
|
+
"variables": {"first_name": "Ada"},
|
|
57
|
+
})
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
A tag with no value and no `{{tag|fallback}}` is refused by name (`template_variables_missing`), never sent blank.
|
|
61
|
+
|
|
62
|
+
## Send a batch
|
|
63
|
+
|
|
64
|
+
Up to 500 messages in one request. Each item is accepted or refused on its own:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
data, error = sc.batch.send([
|
|
68
|
+
{"from": "hello@send.acme.io", "to": "ada@lumen.io", "subject": "Hi Ada", "html": "<p>Hi</p>"},
|
|
69
|
+
{"from": "hello@send.acme.io", "to": "alan@lumen.io", "templateId": "tpl_9f2b7c10ad42", "variables": {"first_name": "Alan"}},
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
for item in data["data"]:
|
|
73
|
+
if item["status"] == "failed":
|
|
74
|
+
print(item["index"], item["error"]["code"])
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Errors
|
|
78
|
+
|
|
79
|
+
API methods never raise for an API refusal or a network failure. They return a `Result` with `data`, `error` and `headers`, and exactly one of `data` and `error` is set. A `Result` unpacks to `(data, error)`:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
result = sc.domains.list()
|
|
83
|
+
result.data # the parsed response, or None
|
|
84
|
+
result.error # an ErrorResponse, or None
|
|
85
|
+
result.headers # response headers (lower-case names), or None when no answer arrived
|
|
86
|
+
result.ok # error is None
|
|
87
|
+
|
|
88
|
+
domains = sc.domains.list().unwrap() # data, or raises SendClipperApiError
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class ErrorResponse:
|
|
94
|
+
name: str # SendClipper's code: "validation_failed", "rate_limited", "network_error", ...
|
|
95
|
+
message: str # a sentence for a person
|
|
96
|
+
status_code: Optional[int] # None when no answer arrived (network, timeout)
|
|
97
|
+
detail: Any = None # code-specific facts
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The iterators (`emails.iterate()` and friends) raise `SendClipperApiError` instead, since a loop has no `error` to hand back. `err.code` is the API's code and `err.status` the HTTP status.
|
|
101
|
+
|
|
102
|
+
## Retries and idempotency
|
|
103
|
+
|
|
104
|
+
Network errors, `429` and `5xx` answers are retried twice with backoff, honouring `Retry-After`. Every JSON write carries an `Idempotency-Key`, and the API answers a repeated key with the first result for 24 hours. A retried send therefore never goes out twice.
|
|
105
|
+
|
|
106
|
+
To make your own retries safe as well (a job that runs again after a crash), pass a key that names the logical operation:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
sc.emails.send(params, idempotency_key=f"order-{order.id}-receipt")
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Request options
|
|
113
|
+
|
|
114
|
+
Every method takes request options as keyword arguments after its own parameters:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
sc.emails.list({"status": "bounced"}, timeout=5, headers={"X-Trace-Id": trace_id})
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
| Option | Meaning |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `idempotency_key` | Your own key for a write |
|
|
123
|
+
| `timeout` | Per-attempt timeout in seconds (default 30) |
|
|
124
|
+
| `max_retries` | Retries for this call (default 2) |
|
|
125
|
+
| `headers` | Extra request headers |
|
|
126
|
+
|
|
127
|
+
Client options: `SendClipper(api_key, base_url=..., timeout=..., max_retries=..., default_headers=..., transport=...)`. `base_url` also reads `SENDCLIPPER_BASE_URL`. `transport` replaces the standard-library HTTP sender, e.g. to go through a proxy library: it receives a `TransportRequest` and returns a `TransportResponse`.
|
|
128
|
+
|
|
129
|
+
## Verify webhooks
|
|
130
|
+
|
|
131
|
+
SendClipper signs each delivery. Verify it against the **raw** body, before parsing:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
import os
|
|
135
|
+
from flask import Flask, request
|
|
136
|
+
from sendclipper import verify_webhook, WebhookVerificationError
|
|
137
|
+
|
|
138
|
+
app = Flask(__name__)
|
|
139
|
+
|
|
140
|
+
@app.post("/hooks/sendclipper")
|
|
141
|
+
def sendclipper_hook():
|
|
142
|
+
try:
|
|
143
|
+
event = verify_webhook(
|
|
144
|
+
request.get_data(), # raw bytes, not request.json
|
|
145
|
+
request.headers,
|
|
146
|
+
os.environ["SENDCLIPPER_WEBHOOK_SECRET"],
|
|
147
|
+
)
|
|
148
|
+
except WebhookVerificationError as err:
|
|
149
|
+
return err.reason, 400
|
|
150
|
+
# event["id"] is stable across retries: deduplicate on it
|
|
151
|
+
print(event["event"], event["data"]["to"])
|
|
152
|
+
return "", 200
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The same call works in Django (`request.body`, `request.headers`) and FastAPI (`await request.body()`, `request.headers`). During a secret rotation a delivery carries two signatures and either one is accepted. A delivery signed more than five minutes away from now is refused. `sc.webhooks.verify(...)` is the same function.
|
|
156
|
+
|
|
157
|
+
To test your handler locally, `signature_header(secret, raw_body)` builds a valid header.
|
|
158
|
+
|
|
159
|
+
## Sandboxes
|
|
160
|
+
|
|
161
|
+
A sandbox traps mail while you develop: nothing is delivered.
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
box = sc.sandboxes.create({"name": "my-app-dev"}).unwrap()
|
|
165
|
+
creds = sc.sandboxes.credentials(box["id"]).unwrap() # SMTP host, port, username, password
|
|
166
|
+
|
|
167
|
+
# Or send through the sandbox's JSON door with its own token, using the same fields as emails.send():
|
|
168
|
+
token = sc.sandboxes.reset_credentials(box["id"]).unwrap()
|
|
169
|
+
sc.sandboxes.send(token["apiToken"], {"from": "app@example.com", "to": "test@example.com", "subject": "Hi", "html": "<p>Hi</p>"})
|
|
170
|
+
|
|
171
|
+
# In a test: wait for the message to arrive
|
|
172
|
+
msg, error = sc.sandboxes.messages.wait_for(box["id"], match=lambda m: m["subject"] == "Hi")
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Everything else
|
|
176
|
+
|
|
177
|
+
| Resource | Methods |
|
|
178
|
+
|---|---|
|
|
179
|
+
| `emails` | `send`, `get`, `list`, `iterate`, `stats` |
|
|
180
|
+
| `batch` | `send` |
|
|
181
|
+
| `domains` | `list`, `create`, `records`, `verify`, `enable_receiving`, `disable_receiving`, `set_region`, `deletion_preview`, `remove` |
|
|
182
|
+
| `templates` | `list`, `get`, `html`, `create`, `update`, `remove`, `duplicate`, `publish`, `unpublish`, `preview`, `set_tags`, `projects` |
|
|
183
|
+
| `templates.tags` | `list`, `create`, `update`, `remove` |
|
|
184
|
+
| `templates.assets` | `list`, `upload` |
|
|
185
|
+
| `sandboxes` | `list`, `get`, `create`, `update`, `remove`, `credentials`, `reset_credentials`, `send` |
|
|
186
|
+
| `sandboxes.messages` | `list`, `get`, `raw`, `remove`, `clear`, `mark_all_read`, `wait_for` |
|
|
187
|
+
| `webhooks` | `list`, `create`, `update`, `remove`, `rotate_secret`, `deliveries`, `verify` |
|
|
188
|
+
| `webhooks.dispatches` | `list`, `iterate`, `get`, `replay`, `replay_all` |
|
|
189
|
+
| `contacts` | `list`, `iterate`, `create`, `import_`, `import_preview`, `export` |
|
|
190
|
+
| `audiences` | `list` |
|
|
191
|
+
| `suppressions` | `list`, `iterate`, `create`, `remove`, `export` |
|
|
192
|
+
| `mailboxes` | `list`, `get`, `create`, `update`, `archive` |
|
|
193
|
+
| `mailboxes.messages` | `list`, `iterate`, `get`, `attachment` |
|
|
194
|
+
| `automations` | `enroll` |
|
|
195
|
+
| `workspace` | `get` |
|
|
196
|
+
| `api_keys` | `list` |
|
|
197
|
+
|
|
198
|
+
`contacts.import_` has a trailing underscore because `import` is a Python keyword. Management routes need a key with **Full access**. A sending-only key can call `emails.send` and `batch.send`.
|
|
199
|
+
|
|
200
|
+
Full API reference: <https://sendclipper.com/docs>.
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
MIT
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sendclipper"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Official Python SDK for the SendClipper email API: send mail, manage domains, templates, sandboxes, webhooks, contacts and suppressions."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "SendClipper", email = "support@sendclipper.com" }]
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
dependencies = []
|
|
15
|
+
keywords = ["sendclipper", "email", "transactional-email", "smtp", "webhooks", "sdk"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Programming Language :: Python :: 3.9",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Programming Language :: Python :: 3.14",
|
|
28
|
+
"Topic :: Communications :: Email",
|
|
29
|
+
"Typing :: Typed",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://sendclipper.com"
|
|
34
|
+
Documentation = "https://sendclipper.com/docs#sdk-python"
|
|
35
|
+
Source = "https://github.com/sendclipper/sendclipper-python"
|
|
36
|
+
Issues = "https://github.com/sendclipper/sendclipper-python/issues"
|
|
37
|
+
Changelog = "https://github.com/sendclipper/sendclipper-python/blob/main/CHANGELOG.md"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.version]
|
|
40
|
+
path = "src/sendclipper/_version.py"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["src/sendclipper"]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.sdist]
|
|
46
|
+
include = ["src/sendclipper", "tests", "README.md", "CHANGELOG.md", "LICENSE"]
|
|
47
|
+
exclude = ["tests/live"]
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
testpaths = ["tests"]
|
|
51
|
+
addopts = "--ignore=tests/live --ignore=tests/wheel"
|
|
52
|
+
|
|
53
|
+
[tool.mypy]
|
|
54
|
+
strict = true
|
|
55
|
+
files = ["src/sendclipper"]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The official Python SDK for the SendClipper email API.
|
|
3
|
+
|
|
4
|
+
::
|
|
5
|
+
|
|
6
|
+
from sendclipper import SendClipper
|
|
7
|
+
|
|
8
|
+
sc = SendClipper("sc_live_...")
|
|
9
|
+
data, error = sc.emails.send({
|
|
10
|
+
"from": "hello@send.acme.io",
|
|
11
|
+
"to": "ada@lumen.io",
|
|
12
|
+
"subject": "Your receipt",
|
|
13
|
+
"html": "<p>Thanks for your order.</p>",
|
|
14
|
+
})
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from ._client import SendClipper
|
|
18
|
+
from ._core import (
|
|
19
|
+
DEFAULT_BASE_URL,
|
|
20
|
+
DownloadedFile,
|
|
21
|
+
ErrorResponse,
|
|
22
|
+
RequestOptions,
|
|
23
|
+
Result,
|
|
24
|
+
Transport,
|
|
25
|
+
TransportRequest,
|
|
26
|
+
TransportResponse,
|
|
27
|
+
urllib_transport,
|
|
28
|
+
)
|
|
29
|
+
from ._errors import (
|
|
30
|
+
SendClipperApiError,
|
|
31
|
+
SendClipperConnectionError,
|
|
32
|
+
SendClipperError,
|
|
33
|
+
WebhookVerificationError,
|
|
34
|
+
)
|
|
35
|
+
from ._version import __version__
|
|
36
|
+
from ._webhooks import (
|
|
37
|
+
WEBHOOK_TOLERANCE_SECONDS,
|
|
38
|
+
parse_signature_header,
|
|
39
|
+
sign_payload,
|
|
40
|
+
signature_header,
|
|
41
|
+
verify_webhook,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"SendClipper",
|
|
46
|
+
"DEFAULT_BASE_URL",
|
|
47
|
+
"DownloadedFile",
|
|
48
|
+
"ErrorResponse",
|
|
49
|
+
"RequestOptions",
|
|
50
|
+
"Result",
|
|
51
|
+
"Transport",
|
|
52
|
+
"TransportRequest",
|
|
53
|
+
"TransportResponse",
|
|
54
|
+
"urllib_transport",
|
|
55
|
+
"SendClipperError",
|
|
56
|
+
"SendClipperApiError",
|
|
57
|
+
"SendClipperConnectionError",
|
|
58
|
+
"WebhookVerificationError",
|
|
59
|
+
"verify_webhook",
|
|
60
|
+
"sign_payload",
|
|
61
|
+
"signature_header",
|
|
62
|
+
"parse_signature_header",
|
|
63
|
+
"WEBHOOK_TOLERANCE_SECONDS",
|
|
64
|
+
"__version__",
|
|
65
|
+
]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Mapping, Optional
|
|
4
|
+
|
|
5
|
+
from ._core import HttpClient, Transport
|
|
6
|
+
from .resources.audience import Audiences, Contacts, Suppressions
|
|
7
|
+
from .resources.domains import Domains
|
|
8
|
+
from .resources.emails import Batch, Emails
|
|
9
|
+
from .resources.mailboxes import Mailboxes
|
|
10
|
+
from .resources.sandboxes import Sandboxes
|
|
11
|
+
from .resources.templates import Templates
|
|
12
|
+
from .resources.webhooks import Webhooks
|
|
13
|
+
from .resources.workspace import ApiKeys, Automations, WorkspaceResource
|
|
14
|
+
|
|
15
|
+
__all__ = ["SendClipper"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SendClipper:
|
|
19
|
+
"""
|
|
20
|
+
The SendClipper API client.
|
|
21
|
+
|
|
22
|
+
::
|
|
23
|
+
|
|
24
|
+
from sendclipper import SendClipper
|
|
25
|
+
|
|
26
|
+
sc = SendClipper("sc_live_...") # or SendClipper(): reads SENDCLIPPER_API_KEY
|
|
27
|
+
data, error = sc.emails.send({
|
|
28
|
+
"from": "hello@acme.io",
|
|
29
|
+
"to": "ada@lumen.io",
|
|
30
|
+
"subject": "Hi",
|
|
31
|
+
"html": "<p>Hi</p>",
|
|
32
|
+
})
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
api_key: Optional[str] = None,
|
|
38
|
+
*,
|
|
39
|
+
base_url: Optional[str] = None,
|
|
40
|
+
timeout: float = 30.0,
|
|
41
|
+
max_retries: int = 2,
|
|
42
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
43
|
+
transport: Optional[Transport] = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""
|
|
46
|
+
:param api_key: A ``sc_live_...`` key. Defaults to ``SENDCLIPPER_API_KEY``.
|
|
47
|
+
:param base_url: Defaults to ``SENDCLIPPER_BASE_URL``, then ``https://sendclipper.com``. No ``/v1``.
|
|
48
|
+
:param timeout: Per attempt, in seconds.
|
|
49
|
+
:param max_retries: Retries after a network error, a 429 or a 5xx. A write is
|
|
50
|
+
only retried with an ``Idempotency-Key``, which the SDK adds to every JSON
|
|
51
|
+
write, so a retried send can never go out twice.
|
|
52
|
+
:param default_headers: Sent on every request.
|
|
53
|
+
:param transport: Your own HTTP sender (a proxy, a test double). Defaults to the standard library.
|
|
54
|
+
"""
|
|
55
|
+
http = HttpClient(
|
|
56
|
+
api_key,
|
|
57
|
+
base_url=base_url,
|
|
58
|
+
timeout=timeout,
|
|
59
|
+
max_retries=max_retries,
|
|
60
|
+
default_headers=default_headers,
|
|
61
|
+
transport=transport,
|
|
62
|
+
)
|
|
63
|
+
self._http = http
|
|
64
|
+
#: The API origin requests go to, without ``/v1``.
|
|
65
|
+
self.base_url = http.base_url
|
|
66
|
+
self.emails = Emails(http)
|
|
67
|
+
self.batch = Batch(http)
|
|
68
|
+
self.domains = Domains(http)
|
|
69
|
+
self.templates = Templates(http)
|
|
70
|
+
self.sandboxes = Sandboxes(http)
|
|
71
|
+
self.webhooks = Webhooks(http)
|
|
72
|
+
self.contacts = Contacts(http)
|
|
73
|
+
self.audiences = Audiences(http)
|
|
74
|
+
self.suppressions = Suppressions(http)
|
|
75
|
+
self.mailboxes = Mailboxes(http)
|
|
76
|
+
self.automations = Automations(http)
|
|
77
|
+
self.workspace = WorkspaceResource(http)
|
|
78
|
+
self.api_keys = ApiKeys(http)
|
|
79
|
+
|
|
80
|
+
def __repr__(self) -> str:
|
|
81
|
+
return f"SendClipper(base_url={self.base_url!r})"
|