sendly-python 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.
Files changed (38) hide show
  1. sendly_python-0.1.0/.github/workflows/ci.yml +52 -0
  2. sendly_python-0.1.0/.github/workflows/release.yml +62 -0
  3. sendly_python-0.1.0/.gitignore +27 -0
  4. sendly_python-0.1.0/CHANGELOG.md +29 -0
  5. sendly_python-0.1.0/LICENSE +21 -0
  6. sendly_python-0.1.0/PKG-INFO +311 -0
  7. sendly_python-0.1.0/README.md +279 -0
  8. sendly_python-0.1.0/pyproject.toml +67 -0
  9. sendly_python-0.1.0/scripts/sync_spec.py +140 -0
  10. sendly_python-0.1.0/src/sendly/__init__.py +62 -0
  11. sendly_python-0.1.0/src/sendly/client.py +252 -0
  12. sendly_python-0.1.0/src/sendly/errors.py +92 -0
  13. sendly_python-0.1.0/src/sendly/py.typed +0 -0
  14. sendly_python-0.1.0/src/sendly/resources/__init__.py +1 -0
  15. sendly_python-0.1.0/src/sendly/resources/_helpers.py +17 -0
  16. sendly_python-0.1.0/src/sendly/resources/contacts.py +93 -0
  17. sendly_python-0.1.0/src/sendly/resources/domains.py +67 -0
  18. sendly_python-0.1.0/src/sendly/resources/emails.py +73 -0
  19. sendly_python-0.1.0/src/sendly/resources/events.py +26 -0
  20. sendly_python-0.1.0/src/sendly/resources/suppression.py +52 -0
  21. sendly_python-0.1.0/src/sendly/resources/templates.py +61 -0
  22. sendly_python-0.1.0/src/sendly/resources/verify.py +26 -0
  23. sendly_python-0.1.0/src/sendly/resources/webhooks.py +76 -0
  24. sendly_python-0.1.0/src/sendly/types.py +84 -0
  25. sendly_python-0.1.0/src/sendly/webhook_utils.py +127 -0
  26. sendly_python-0.1.0/tests/fixtures/openapi.json +5729 -0
  27. sendly_python-0.1.0/tests/support.py +50 -0
  28. sendly_python-0.1.0/tests/test_client.py +189 -0
  29. sendly_python-0.1.0/tests/test_contacts.py +97 -0
  30. sendly_python-0.1.0/tests/test_contract.py +255 -0
  31. sendly_python-0.1.0/tests/test_domains.py +53 -0
  32. sendly_python-0.1.0/tests/test_emails.py +94 -0
  33. sendly_python-0.1.0/tests/test_events.py +63 -0
  34. sendly_python-0.1.0/tests/test_suppression.py +49 -0
  35. sendly_python-0.1.0/tests/test_templates.py +56 -0
  36. sendly_python-0.1.0/tests/test_verify.py +51 -0
  37. sendly_python-0.1.0/tests/test_webhook_verify.py +115 -0
  38. sendly_python-0.1.0/tests/test_webhooks.py +66 -0
@@ -0,0 +1,52 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ schedule:
8
+ # Mondays 06:00 UTC -- surface live OpenAPI drift even without a push.
9
+ - cron: "0 6 * * 1"
10
+
11
+ # Public repo -> GitHub-hosted standard runners (free for public repos).
12
+ # The org's self-hosted Warp runner group (warp-ubuntu-latest-*) is deliberately
13
+ # NOT used here: it may not be authorized for this repository, and the standard
14
+ # ubuntu-latest runners are sufficient for a pure-Python package.
15
+ jobs:
16
+ test:
17
+ runs-on: ubuntu-latest
18
+ strategy:
19
+ fail-fast: false
20
+ matrix:
21
+ python-version: ["3.10", "3.13"]
22
+ steps:
23
+ - uses: actions/checkout@v6.0.3
24
+ - uses: actions/setup-python@v5.6.0
25
+ with:
26
+ python-version: ${{ matrix.python-version }}
27
+ - name: Install package with dev dependencies
28
+ run: |
29
+ python -m pip install --upgrade pip
30
+ pip install -e ".[dev]"
31
+ - name: Ruff lint
32
+ run: ruff check .
33
+ - name: Ruff format check
34
+ run: ruff format --check .
35
+ - name: Mypy (strict)
36
+ run: mypy src
37
+ - name: Contract tests (SDK surface vs vendored OpenAPI)
38
+ run: pytest tests/test_contract.py
39
+ - name: Pytest
40
+ run: pytest
41
+ # Non-blocking: diff the vendored spec against the live API so drift shows
42
+ # up as a warning annotation without failing the build. Runs once (on the
43
+ # newest interpreter) to avoid duplicate live fetches and annotations.
44
+ - name: Live OpenAPI drift check (non-blocking)
45
+ id: spec_drift
46
+ if: matrix.python-version == '3.13'
47
+ continue-on-error: true
48
+ run: python scripts/sync_spec.py --check
49
+ - name: Annotate OpenAPI drift
50
+ if: matrix.python-version == '3.13' && steps.spec_drift.outcome == 'failure'
51
+ run: |
52
+ echo "::warning title=OpenAPI spec drift::Vendored tests/fixtures/openapi.json differs from the live spec at https://api.sendly.now/api/openapi.json. Run 'python scripts/sync_spec.py' and commit the refreshed copy."
@@ -0,0 +1,62 @@
1
+ name: Release
2
+
3
+ # Publishes sendly-python to PyPI when a version tag (vX.Y.Z) is pushed.
4
+ # Disjoint from ci.yml (push/PR to main + weekly cron): this runs ONLY on tags,
5
+ # re-runs the full gate, then publishes. A red SDK can never ship.
6
+ on:
7
+ push:
8
+ tags: ["v*"]
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ jobs:
14
+ publish:
15
+ name: Test, build & publish to PyPI
16
+ runs-on: ubuntu-latest
17
+ environment: pypi # must match the environment on the PyPI trusted publisher
18
+ permissions:
19
+ id-token: write # OIDC for PyPI Trusted Publishing (no token stored)
20
+ steps:
21
+ - uses: actions/checkout@v6.0.3
22
+
23
+ - uses: actions/setup-python@v5.6.0
24
+ with:
25
+ python-version: "3.13"
26
+
27
+ - name: Install build + dev toolchain
28
+ run: |
29
+ python -m pip install --upgrade pip build
30
+ pip install -e ".[dev]"
31
+
32
+ # Version-consistency gate: the pushed tag must equal both the
33
+ # pyproject.toml version and the SDK_VERSION constant (User-Agent header).
34
+ - name: Assert tag matches pyproject + SDK_VERSION
35
+ run: |
36
+ TAG="${GITHUB_REF_NAME#v}"
37
+ VER=$(python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
38
+ SDK=$(python -c "from sendly import SDK_VERSION;print(SDK_VERSION)")
39
+ echo "tag=$TAG pyproject=$VER SDK_VERSION=$SDK"
40
+ test "$TAG" = "$VER" || { echo "::error::tag $TAG != pyproject $VER"; exit 1; }
41
+ test "$TAG" = "$SDK" || { echo "::error::tag $TAG != SDK_VERSION $SDK"; exit 1; }
42
+
43
+ - name: Ruff lint
44
+ run: ruff check .
45
+
46
+ - name: Ruff format check
47
+ run: ruff format --check .
48
+
49
+ - name: Mypy (strict)
50
+ run: mypy src
51
+
52
+ - name: Contract tests (SDK surface vs vendored OpenAPI)
53
+ run: pytest tests/test_contract.py
54
+
55
+ - name: Pytest
56
+ run: pytest
57
+
58
+ - name: Build sdist + wheel
59
+ run: python -m build
60
+
61
+ - name: Publish to PyPI (Trusted Publishing / OIDC)
62
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,27 @@
1
+ # Virtual environments
2
+ .venv/
3
+ venv/
4
+ env/
5
+
6
+ # Python caches / build artifacts
7
+ __pycache__/
8
+ *.py[cod]
9
+ *.egg-info/
10
+ dist/
11
+ build/
12
+ .eggs/
13
+
14
+ # Tooling caches
15
+ .mypy_cache/
16
+ .ruff_cache/
17
+ .pytest_cache/
18
+ .coverage
19
+ htmlcov/
20
+
21
+ # Editor / OS
22
+ .idea/
23
+ .vscode/
24
+ .DS_Store
25
+
26
+ # Local scratch logs
27
+ *.log
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ All notable changes to `sendly-python` are documented here. This project adheres to
4
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+
6
+ ## [Unreleased]
7
+
8
+ ### Changed
9
+
10
+ - Re-synced the vendored OpenAPI spec (`tests/fixtures/openapi.json`) to the
11
+ committed monorepo contract. The client stays thin (opaque `Mapping` bodies),
12
+ so these are contract/behaviour clarifications rather than method-signature
13
+ changes:
14
+ - **Deletes now return HTTP `200` with the deleted resource's id** (was `204`
15
+ No Content) for `contacts.delete` and `templates.delete`. The SDK still
16
+ discards the body and returns `None` — no consumer change.
17
+ - **Invalid input now raises `SendlyValidationError` from HTTP `422`**
18
+ (`error_code == "VALIDATION_ERROR"`) with a per-field breakdown at
19
+ `err.body["error"]["details"]["errors"]`. Previously invalid input came back
20
+ as `400`. Both `400` and `422` map to `SendlyValidationError`, so
21
+ `except SendlyValidationError` continues to catch validation failures.
22
+ - **Contacts bulk ops (`bulk_create`, `bulk_delete`) against an unresolved
23
+ project now return `422 VALIDATION_ERROR`** (was a `NO_PROJECT` error).
24
+ - **`templates.list` is cursor-paginated** (`limit` / `cursor`) — the former
25
+ `page` / `pageSize` query params are gone. `contacts.list` was already
26
+ cursor-based and is unchanged.
27
+ - Error envelopes on migrated routes now include `success: false` alongside
28
+ `error.{message,code}`; error parsing reads `message`/`code` and is
29
+ unaffected by the additive fields.
@@ -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.
@@ -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).