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.
@@ -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
+ [![CI](https://github.com/DevinoSolutions/sendly-python/actions/workflows/ci.yml/badge.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.