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.
- sendly_python-0.1.0/.github/workflows/ci.yml +52 -0
- sendly_python-0.1.0/.github/workflows/release.yml +62 -0
- sendly_python-0.1.0/.gitignore +27 -0
- sendly_python-0.1.0/CHANGELOG.md +29 -0
- sendly_python-0.1.0/LICENSE +21 -0
- sendly_python-0.1.0/PKG-INFO +311 -0
- sendly_python-0.1.0/README.md +279 -0
- sendly_python-0.1.0/pyproject.toml +67 -0
- sendly_python-0.1.0/scripts/sync_spec.py +140 -0
- sendly_python-0.1.0/src/sendly/__init__.py +62 -0
- sendly_python-0.1.0/src/sendly/client.py +252 -0
- sendly_python-0.1.0/src/sendly/errors.py +92 -0
- sendly_python-0.1.0/src/sendly/py.typed +0 -0
- sendly_python-0.1.0/src/sendly/resources/__init__.py +1 -0
- sendly_python-0.1.0/src/sendly/resources/_helpers.py +17 -0
- sendly_python-0.1.0/src/sendly/resources/contacts.py +93 -0
- sendly_python-0.1.0/src/sendly/resources/domains.py +67 -0
- sendly_python-0.1.0/src/sendly/resources/emails.py +73 -0
- sendly_python-0.1.0/src/sendly/resources/events.py +26 -0
- sendly_python-0.1.0/src/sendly/resources/suppression.py +52 -0
- sendly_python-0.1.0/src/sendly/resources/templates.py +61 -0
- sendly_python-0.1.0/src/sendly/resources/verify.py +26 -0
- sendly_python-0.1.0/src/sendly/resources/webhooks.py +76 -0
- sendly_python-0.1.0/src/sendly/types.py +84 -0
- sendly_python-0.1.0/src/sendly/webhook_utils.py +127 -0
- sendly_python-0.1.0/tests/fixtures/openapi.json +5729 -0
- sendly_python-0.1.0/tests/support.py +50 -0
- sendly_python-0.1.0/tests/test_client.py +189 -0
- sendly_python-0.1.0/tests/test_contacts.py +97 -0
- sendly_python-0.1.0/tests/test_contract.py +255 -0
- sendly_python-0.1.0/tests/test_domains.py +53 -0
- sendly_python-0.1.0/tests/test_emails.py +94 -0
- sendly_python-0.1.0/tests/test_events.py +63 -0
- sendly_python-0.1.0/tests/test_suppression.py +49 -0
- sendly_python-0.1.0/tests/test_templates.py +56 -0
- sendly_python-0.1.0/tests/test_verify.py +51 -0
- sendly_python-0.1.0/tests/test_webhook_verify.py +115 -0
- 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
|
+
[](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).
|