sendly-python 0.1.0__py3-none-any.whl
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.
- sendly/__init__.py +62 -0
- sendly/client.py +252 -0
- sendly/errors.py +92 -0
- sendly/py.typed +0 -0
- sendly/resources/__init__.py +1 -0
- sendly/resources/_helpers.py +17 -0
- sendly/resources/contacts.py +93 -0
- sendly/resources/domains.py +67 -0
- sendly/resources/emails.py +73 -0
- sendly/resources/events.py +26 -0
- sendly/resources/suppression.py +52 -0
- sendly/resources/templates.py +61 -0
- sendly/resources/verify.py +26 -0
- sendly/resources/webhooks.py +76 -0
- sendly/types.py +84 -0
- sendly/webhook_utils.py +127 -0
- sendly_python-0.1.0.dist-info/METADATA +311 -0
- sendly_python-0.1.0.dist-info/RECORD +20 -0
- sendly_python-0.1.0.dist-info/WHEEL +4 -0
- sendly_python-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sendly-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Sendly Python SDK
|
|
5
|
+
Project-URL: Homepage, https://sendly.now
|
|
6
|
+
Project-URL: Documentation, https://docs.sendly.now
|
|
7
|
+
Project-URL: Repository, https://github.com/DevinoSolutions/sendly-python
|
|
8
|
+
Author-email: Devino Solutions <dev@devino.ca>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api,email,sdk,sendly,transactional-email,webhooks
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
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 :: Only
|
|
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: Topic :: Communications :: Email
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: httpx==0.28.1
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: mypy==1.15.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest==8.3.4; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff==0.9.6; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# Sendly Python SDK
|
|
34
|
+
|
|
35
|
+
Official Python SDK for the [Sendly](https://sendly.now) REST API — transactional
|
|
36
|
+
email, contacts, events, domains, templates, email verification, webhooks, and
|
|
37
|
+
suppression.
|
|
38
|
+
|
|
39
|
+
[](https://github.com/DevinoSolutions/sendly-python/actions/workflows/ci.yml)
|
|
40
|
+
|
|
41
|
+
- Full type hints (ships `py.typed`), `mypy --strict` clean.
|
|
42
|
+
- One small runtime dependency: [`httpx`](https://www.python-httpx.org/).
|
|
43
|
+
- Fail-loud by design: no silent fallbacks, no degraded mode.
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install sendly-python
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The distribution is published as `sendly-python`; the import name is unchanged:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import sendly
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Alternatively, install the latest `main` directly from GitHub:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install git+https://github.com/DevinoSolutions/sendly-python.git
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Requires Python 3.10+.
|
|
64
|
+
|
|
65
|
+
## Quickstart
|
|
66
|
+
|
|
67
|
+
The client reads your API key from the `SENDLY_API_KEY` environment variable:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from sendly import Sendly
|
|
71
|
+
|
|
72
|
+
sendly = Sendly() # reads SENDLY_API_KEY
|
|
73
|
+
|
|
74
|
+
result = sendly.emails.send(
|
|
75
|
+
{
|
|
76
|
+
"from": "hello@yourdomain.com",
|
|
77
|
+
"to": "customer@example.com",
|
|
78
|
+
"subject": "Welcome aboard",
|
|
79
|
+
"body": "<h1>Thanks for signing up!</h1>",
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
print(result["id"])
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Or pass the key explicitly:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
sendly = Sendly(api_key="sk_live_...")
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
If neither an explicit key nor `SENDLY_API_KEY` is set, the constructor raises a
|
|
92
|
+
`SendlyError` immediately.
|
|
93
|
+
|
|
94
|
+
### Options
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
sendly = Sendly(
|
|
98
|
+
api_key="sk_live_...",
|
|
99
|
+
base_url="https://api.sendly.now", # override for staging/self-hosted
|
|
100
|
+
timeout=30.0, # per-request seconds; 0 or None disables
|
|
101
|
+
default_headers={"X-Trace-Id": "..."},
|
|
102
|
+
)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The client holds an internal connection pool. Reuse a single instance, and close
|
|
106
|
+
it when done (or use it as a context manager):
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
with Sendly() as sendly:
|
|
110
|
+
sendly.emails.send({...})
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Usage by resource
|
|
114
|
+
|
|
115
|
+
### Emails
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
# Single send (pass idempotency_key to dedupe replays for 24h)
|
|
119
|
+
sendly.emails.send({"from": "a@you.com", "to": "b@them.com", "subject": "Hi", "body": "<p>Hi</p>"},
|
|
120
|
+
idempotency_key="order-42-receipt")
|
|
121
|
+
|
|
122
|
+
# Batch send (up to 100)
|
|
123
|
+
sendly.emails.batch({"emails": [{"from": "a@you.com", "to": "b@them.com", "subject": "Hi", "body": "<p>Hi</p>"}]})
|
|
124
|
+
|
|
125
|
+
# List, get, cancel a scheduled send
|
|
126
|
+
sendly.emails.list({"limit": 20, "status": "DELIVERED"})
|
|
127
|
+
sendly.emails.get("em_123")
|
|
128
|
+
sendly.emails.cancel_schedule("em_123")
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Contacts
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
sendly.contacts.create({"email": "user@example.com", "subscribed": True})
|
|
135
|
+
sendly.contacts.upsert({"email": "user@example.com", "data": {"plan": "pro"}})
|
|
136
|
+
sendly.contacts.list({"limit": 50, "search": "example.com"})
|
|
137
|
+
sendly.contacts.get("c_123")
|
|
138
|
+
sendly.contacts.update("c_123", {"data": {"plan": "enterprise"}})
|
|
139
|
+
sendly.contacts.delete("c_123")
|
|
140
|
+
sendly.contacts.bulk_create({"contacts": [{"email": "a@x.com"}, {"email": "b@x.com"}]})
|
|
141
|
+
sendly.contacts.bulk_delete({"emails": ["a@x.com"]})
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Events
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
# Track a custom event for a contact (accepts sk_* and pk_* keys)
|
|
148
|
+
result = sendly.events.track({"event": "signup", "email": "user@example.com"})
|
|
149
|
+
print(result["contact"], result["timestamp"])
|
|
150
|
+
|
|
151
|
+
# Attach an arbitrary payload and set subscription state
|
|
152
|
+
sendly.events.track({"event": "purchase", "email": "user@example.com",
|
|
153
|
+
"subscribed": True, "data": {"plan": "pro", "amount": 42}})
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Domains
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
sendly.domains.create({"domain": "mail.yourdomain.com", "region": "us-east-1"})
|
|
160
|
+
sendly.domains.list()
|
|
161
|
+
sendly.domains.get("d_123")
|
|
162
|
+
sendly.domains.verify("d_123")
|
|
163
|
+
sendly.domains.get_verification("d_123")
|
|
164
|
+
sendly.domains.delete("d_123")
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Templates
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
sendly.templates.create({"name": "Welcome", "subject": "Welcome", "body": "<p>Hi</p>",
|
|
171
|
+
"from": "a@you.com", "type": "MARKETING"})
|
|
172
|
+
sendly.templates.list({"limit": 25}) # cursor pagination: pass {"cursor": ...} for the next page
|
|
173
|
+
sendly.templates.get("t_123")
|
|
174
|
+
sendly.templates.update("t_123", {"name": "Welcome v2"})
|
|
175
|
+
sendly.templates.delete("t_123")
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Verify
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
# Validate an email address (syntax, MX, disposable domains, plus-addressing).
|
|
182
|
+
# Open endpoint — the SDK still sends your API key, which the API ignores.
|
|
183
|
+
result = sendly.verify.email({"email": "user@example.com"})
|
|
184
|
+
if not result["valid"]:
|
|
185
|
+
print("Rejected:", result.get("reason"))
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Webhooks
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
created = sendly.webhooks.create({"url": "https://you.com/hook", "eventTypes": ["email.delivered"]})
|
|
192
|
+
# Store the signing secret now — it is only returned in full at creation/rotation.
|
|
193
|
+
sendly.webhooks.list()
|
|
194
|
+
sendly.webhooks.get("w_123")
|
|
195
|
+
sendly.webhooks.update("w_123", {"status": "PAUSED"})
|
|
196
|
+
sendly.webhooks.rotate_secret("w_123")
|
|
197
|
+
sendly.webhooks.list_calls("w_123", {"limit": 20})
|
|
198
|
+
sendly.webhooks.delete("w_123")
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Suppression
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
sendly.suppression.add({"email": "bounce@example.com", "reason": "MANUAL"})
|
|
205
|
+
sendly.suppression.list({"reason": "MANUAL", "limit": 100})
|
|
206
|
+
sendly.suppression.get("bounce@example.com")
|
|
207
|
+
sendly.suppression.remove("bounce@example.com")
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Error handling
|
|
211
|
+
|
|
212
|
+
Every non-2xx response raises a `SendlyError` subclass carrying `status_code`,
|
|
213
|
+
`error_code`, `message`, and the raw `body`:
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
from sendly import Sendly, SendlyValidationError, SendlyRateLimitError, SendlyError
|
|
217
|
+
|
|
218
|
+
sendly = Sendly()
|
|
219
|
+
try:
|
|
220
|
+
sendly.emails.send({"from": "a@you.com", "to": "b@them.com", "subject": "Hi", "body": "<p>Hi</p>"})
|
|
221
|
+
except SendlyValidationError as err:
|
|
222
|
+
print("Bad request:", err.error_code, err.message)
|
|
223
|
+
except SendlyRateLimitError:
|
|
224
|
+
print("Slow down and retry with backoff")
|
|
225
|
+
except SendlyError as err:
|
|
226
|
+
print("Sendly error", err.status_code, err.message)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
| Exception | HTTP status |
|
|
230
|
+
| --- | --- |
|
|
231
|
+
| `SendlyValidationError` | 400, 422 |
|
|
232
|
+
| `SendlyAuthenticationError` | 401 |
|
|
233
|
+
| `SendlyPermissionError` | 403 |
|
|
234
|
+
| `SendlyNotFoundError` | 404 |
|
|
235
|
+
| `SendlyConflictError` | 409 |
|
|
236
|
+
| `SendlyRateLimitError` | 429 |
|
|
237
|
+
| `SendlyServerError` | 5xx |
|
|
238
|
+
| `SendlyConnectionError` | transport failure (status `0`) |
|
|
239
|
+
|
|
240
|
+
All inherit from `SendlyError`.
|
|
241
|
+
|
|
242
|
+
Invalid input raises `SendlyValidationError`. Migrated routes report it as HTTP
|
|
243
|
+
`422` with `error_code == "VALIDATION_ERROR"` and a per-field breakdown under
|
|
244
|
+
`err.body["error"]["details"]["errors"]`; legacy/malformed requests still use
|
|
245
|
+
`400`. Both surface as `SendlyValidationError`.
|
|
246
|
+
|
|
247
|
+
## Verifying webhooks
|
|
248
|
+
|
|
249
|
+
Every delivery is signed. Verify it against the **raw** request body — do not
|
|
250
|
+
parse the JSON first. Two headers are sent:
|
|
251
|
+
|
|
252
|
+
- `X-Sendly-Signature` — bare lowercase hex HMAC-SHA256 of `"{timestamp}.{body}"`
|
|
253
|
+
(no `sha256=` prefix).
|
|
254
|
+
- `X-Sendly-Timestamp` — the signing time as a **millisecond** Unix epoch.
|
|
255
|
+
|
|
256
|
+
`verify_signature` also enforces replay protection: a delivery whose timestamp is
|
|
257
|
+
more than `DEFAULT_TOLERANCE_MS` (5 minutes) from now is rejected. Pass
|
|
258
|
+
`tolerance_ms=math.inf` to disable that check.
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
import os
|
|
262
|
+
from flask import Flask, request
|
|
263
|
+
from sendly import construct_event
|
|
264
|
+
|
|
265
|
+
app = Flask(__name__)
|
|
266
|
+
|
|
267
|
+
@app.post("/webhook")
|
|
268
|
+
def webhook():
|
|
269
|
+
payload = request.get_data() # raw bytes
|
|
270
|
+
signature = request.headers.get("X-Sendly-Signature", "")
|
|
271
|
+
timestamp = request.headers.get("X-Sendly-Timestamp", "")
|
|
272
|
+
secret = os.environ["SENDLY_WEBHOOK_SECRET"]
|
|
273
|
+
try:
|
|
274
|
+
event = construct_event(payload, signature, timestamp, secret)
|
|
275
|
+
except ValueError:
|
|
276
|
+
return "Invalid signature", 400
|
|
277
|
+
# handle event["event"], event["data"], ...
|
|
278
|
+
return "", 200
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`verify_signature(payload, signature, timestamp, secret, *, tolerance_ms=...) -> bool`
|
|
282
|
+
is also exported if you only need the boolean check. Both use a constant-time
|
|
283
|
+
comparison and reject a stale or non-numeric timestamp.
|
|
284
|
+
|
|
285
|
+
## Async
|
|
286
|
+
|
|
287
|
+
Only a synchronous client ships in v0.1. An `httpx.AsyncClient`-backed async
|
|
288
|
+
variant is planned.
|
|
289
|
+
|
|
290
|
+
## Development
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
python -m venv .venv
|
|
294
|
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
295
|
+
pip install -e ".[dev]"
|
|
296
|
+
|
|
297
|
+
ruff check .
|
|
298
|
+
ruff format --check .
|
|
299
|
+
mypy src
|
|
300
|
+
pytest
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Tests are fully hermetic (httpx `MockTransport`) and hit no network.
|
|
304
|
+
|
|
305
|
+
## Documentation
|
|
306
|
+
|
|
307
|
+
Full API reference: <https://docs.sendly.now>
|
|
308
|
+
|
|
309
|
+
## License
|
|
310
|
+
|
|
311
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
sendly/__init__.py,sha256=EK_NplzmfmE_d_CcW7epJdKbZLVFn5zK3culailQLkA,1764
|
|
2
|
+
sendly/client.py,sha256=fiiRtDeV3Wt1nLA2ERsK-rlSNg9XBzj4wNgiUA-kYY0,9467
|
|
3
|
+
sendly/errors.py,sha256=7nlE2ixqRXS21zYe5qwb-rTUyv1Wx3yI9uThsf4sJoQ,3394
|
|
4
|
+
sendly/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
sendly/types.py,sha256=YiHQaggce5KIEr5cZMg0Ks923mDX9kNUrpWLujvf00k,2159
|
|
6
|
+
sendly/webhook_utils.py,sha256=epmK860gLknaElxfXdVD3h_9ASkjWQsyl1exUC8qBL4,4762
|
|
7
|
+
sendly/resources/__init__.py,sha256=Nf7zjcFjsztXsGtrvxq3KGacX7SwDgSf3r0nAVHLvPY,73
|
|
8
|
+
sendly/resources/_helpers.py,sha256=W6AlzXuqH53Vk1dIxuQm_-Vpq9gmZvLzKPRionjH9wQ,550
|
|
9
|
+
sendly/resources/contacts.py,sha256=7DwbGXvwUw8Tf5WfIUU0ZEvJs3ZBIqulkbN_Ke0-ilc,3274
|
|
10
|
+
sendly/resources/domains.py,sha256=m69uHKGhmtVlzo3QAmMmMHvmpc0T3JAv5izTWxDpxhc,2368
|
|
11
|
+
sendly/resources/emails.py,sha256=3LHxre7lJfa_6z1F9Rzmdh5QDwhYpwTWaAJZGVCLsY8,2390
|
|
12
|
+
sendly/resources/events.py,sha256=hEy1HCRWfPS_Mc7xxWZtgqj9R8iYsgPRR1YdZuGuP1c,785
|
|
13
|
+
sendly/resources/suppression.py,sha256=NNIhmkq1bC2qe1i4nSGZDCvuGBT7JddIrkXlVP1qASU,1716
|
|
14
|
+
sendly/resources/templates.py,sha256=DhewoFRqxGfjo0brAtG9rI6GHTCWNv4J01TylB4luXI,2104
|
|
15
|
+
sendly/resources/verify.py,sha256=QyVNmrzZ4Nj87YWxMPt9wlovcCyZutV9eGmn49c0EEg,818
|
|
16
|
+
sendly/resources/webhooks.py,sha256=rooES4-HMy7mhC9D1bzVDUpJ34cf1yZkVilkoWEIBEQ,2763
|
|
17
|
+
sendly_python-0.1.0.dist-info/METADATA,sha256=_CFNo8me4v-Duk1M_PWpiiq_FKQNcfd3ASOh4jSN-IM,9560
|
|
18
|
+
sendly_python-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
19
|
+
sendly_python-0.1.0.dist-info/licenses/LICENSE,sha256=TZHT_f_vV-xujWjSy7EkTybNT6iivrfGHLd-rR1Xl7c,1073
|
|
20
|
+
sendly_python-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Devino Solutions
|
|
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.
|