sendly-python 0.2.0__tar.gz → 1.0.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.2.0 → sendly_python-1.0.0}/.github/workflows/ci.yml +18 -6
- {sendly_python-0.2.0 → sendly_python-1.0.0}/CHANGELOG.md +66 -1
- {sendly_python-0.2.0 → sendly_python-1.0.0}/PKG-INFO +180 -10
- {sendly_python-0.2.0 → sendly_python-1.0.0}/README.md +179 -9
- {sendly_python-0.2.0 → sendly_python-1.0.0}/pyproject.toml +1 -1
- sendly_python-1.0.0/scripts/sync_spec.py +301 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/src/sendly/__init__.py +7 -1
- {sendly_python-0.2.0 → sendly_python-1.0.0}/src/sendly/client.py +6 -1
- {sendly_python-0.2.0 → sendly_python-1.0.0}/src/sendly/resources/domains.py +16 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/src/sendly/resources/emails.py +50 -3
- sendly_python-1.0.0/src/sendly/resources/mailboxes.py +75 -0
- sendly_python-1.0.0/src/sendly/resources/projects.py +33 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/src/sendly/types.py +24 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/tests/fixtures/openapi.json +10157 -6727
- {sendly_python-0.2.0 → sendly_python-1.0.0}/tests/test_contract.py +107 -16
- {sendly_python-0.2.0 → sendly_python-1.0.0}/tests/test_domains.py +17 -0
- sendly_python-1.0.0/tests/test_emails.py +225 -0
- sendly_python-1.0.0/tests/test_mailboxes.py +99 -0
- sendly_python-1.0.0/tests/test_projects.py +58 -0
- sendly_python-0.2.0/scripts/sync_spec.py +0 -140
- sendly_python-0.2.0/tests/test_emails.py +0 -94
- {sendly_python-0.2.0 → sendly_python-1.0.0}/.github/workflows/release.yml +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/.gitignore +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/LICENSE +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/src/sendly/errors.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/src/sendly/py.typed +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/src/sendly/resources/__init__.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/src/sendly/resources/_helpers.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/src/sendly/resources/_pagination.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/src/sendly/resources/analytics.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/src/sendly/resources/campaigns.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/src/sendly/resources/contacts.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/src/sendly/resources/events.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/src/sendly/resources/lists.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/src/sendly/resources/segments.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/src/sendly/resources/suppression.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/src/sendly/resources/templates.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/src/sendly/resources/usage.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/src/sendly/resources/verify.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/src/sendly/resources/webhooks.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/src/sendly/resources/workflows.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/src/sendly/webhook_utils.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/tests/support.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/tests/test_analytics.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/tests/test_campaigns.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/tests/test_client.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/tests/test_contacts.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/tests/test_errors_problem.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/tests/test_events.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/tests/test_lists.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/tests/test_segments.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/tests/test_suppression.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/tests/test_templates.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/tests/test_usage.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/tests/test_verify.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/tests/test_webhook_verify.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/tests/test_webhooks.py +0 -0
- {sendly_python-0.2.0 → sendly_python-1.0.0}/tests/test_workflows.py +0 -0
|
@@ -5,7 +5,9 @@ on:
|
|
|
5
5
|
branches: [main]
|
|
6
6
|
pull_request:
|
|
7
7
|
schedule:
|
|
8
|
-
# Mondays 06:00 UTC -- surface
|
|
8
|
+
# Mondays 06:00 UTC -- surface OpenAPI drift even without a push. Only does
|
|
9
|
+
# anything once the SENDLY_OPENAPI_URL repository variable is set; see the
|
|
10
|
+
# drift step at the end of this file.
|
|
9
11
|
- cron: "0 6 * * 1"
|
|
10
12
|
|
|
11
13
|
# Public repo -> GitHub-hosted standard runners (free for public repos).
|
|
@@ -38,15 +40,25 @@ jobs:
|
|
|
38
40
|
run: pytest tests/test_contract.py
|
|
39
41
|
- name: Pytest
|
|
40
42
|
run: pytest
|
|
41
|
-
# Non-blocking: diff the vendored spec against the
|
|
42
|
-
# up as a warning annotation without failing the build. Runs
|
|
43
|
-
# newest interpreter) to avoid duplicate
|
|
44
|
-
|
|
43
|
+
# Non-blocking: diff the vendored spec against the reference contract so
|
|
44
|
+
# drift shows up as a warning annotation without failing the build. Runs
|
|
45
|
+
# once (on the newest interpreter) to avoid duplicate reads and annotations.
|
|
46
|
+
#
|
|
47
|
+
# This step previously fetched https://api.sendly.now on every push, every
|
|
48
|
+
# pull request (forks included) and every weekly cron. Syncing the SDK spec
|
|
49
|
+
# from production is forbidden, so the source is now explicit: the step
|
|
50
|
+
# compares against whatever the SENDLY_OPENAPI_URL repository variable
|
|
51
|
+
# names, and SKIPS with a notice when that variable is unset. Until a
|
|
52
|
+
# maintainer sets it to a non-production contract, this step and the weekly
|
|
53
|
+
# schedule above are inert by design.
|
|
54
|
+
- name: OpenAPI drift check (non-blocking)
|
|
45
55
|
id: spec_drift
|
|
46
56
|
if: matrix.python-version == '3.13'
|
|
47
57
|
continue-on-error: true
|
|
58
|
+
env:
|
|
59
|
+
SENDLY_OPENAPI_URL: ${{ vars.SENDLY_OPENAPI_URL }}
|
|
48
60
|
run: python scripts/sync_spec.py --check
|
|
49
61
|
- name: Annotate OpenAPI drift
|
|
50
62
|
if: matrix.python-version == '3.13' && steps.spec_drift.outcome == 'failure'
|
|
51
63
|
run: |
|
|
52
|
-
echo "::warning title=OpenAPI spec drift::Vendored tests/fixtures/openapi.json differs from the
|
|
64
|
+
echo "::warning title=OpenAPI spec drift::Vendored tests/fixtures/openapi.json differs from the contract at \$SENDLY_OPENAPI_URL. Run 'SENDLY_OPENAPI_URL=... python scripts/sync_spec.py' and commit the refreshed copy."
|
|
@@ -3,7 +3,72 @@
|
|
|
3
3
|
All notable changes to `sendly-python` are documented here. This project adheres to
|
|
4
4
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
5
|
|
|
6
|
-
## [
|
|
6
|
+
## [1.0.0] - 2026-09-02
|
|
7
|
+
|
|
8
|
+
The default send moves to the versioned API. Everything else in this release is
|
|
9
|
+
additive: the operations an API key can actually reach that the SDK did not yet
|
|
10
|
+
expose.
|
|
11
|
+
|
|
12
|
+
### Breaking
|
|
13
|
+
|
|
14
|
+
- **`emails.send()` now posts to `POST /api/v1/emails`** and returns the bare
|
|
15
|
+
`202` receipt, `{id, status, to, from}`, where `status` is a real delivery
|
|
16
|
+
state. Before 1.0 it posted to the legacy `POST /api/emails`, which answered
|
|
17
|
+
with row ids and **no** delivery status, and fanned an array `to` out to
|
|
18
|
+
several recipients. What changes for a caller:
|
|
19
|
+
- one recipient in `to`, with `cc` / `bcc` to copy others (an array `to` is
|
|
20
|
+
no longer accepted);
|
|
21
|
+
- the result is the receipt, not `{emails, timestamp}` — read
|
|
22
|
+
`receipt["id"]` and `receipt["status"]` instead of
|
|
23
|
+
`result["emails"][0]["email"]`;
|
|
24
|
+
- failures arrive as RFC 9457 problem documents, raised as the **same**
|
|
25
|
+
exception classes, so `except` blocks are unchanged; `err.error_code` is now
|
|
26
|
+
the lowercase v1 registry value (`validation_error`, not
|
|
27
|
+
`VALIDATION_ERROR`) and `err.request_id` / `err.field_errors` are populated.
|
|
28
|
+
|
|
29
|
+
The pre-1.0 behaviour is kept, unchanged, as **`emails.send_legacy()`** — the
|
|
30
|
+
escape hatch for a caller that depends on the fan-out or the envelope.
|
|
31
|
+
Renaming a call from `send` to `send_legacy` is a complete migration; adopting
|
|
32
|
+
the new default means reading the receipt instead of the envelope.
|
|
33
|
+
|
|
34
|
+
Why now: the legacy send cannot tell a caller whether a message went anywhere,
|
|
35
|
+
and the versioned one can. Nothing is published against 0.x, so the cost of
|
|
36
|
+
the move is lowest today and only rises.
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- **`emails.send_legacy()`** — the pre-1.0 `send()`, byte for byte. See Breaking.
|
|
41
|
+
- **`emails.send_test()`** — sandbox test send. The sandbox address is the
|
|
42
|
+
*sender*; the mail lands in the project owner's own verified inbox. Naming a
|
|
43
|
+
`from` is refused rather than ignored. Takes no `idempotency_key`.
|
|
44
|
+
- **`mailboxes` resource, reads only** — `list()`, `get(id)` (which carries the
|
|
45
|
+
IMAP/SMTP `settings` a mail client needs) and `list_app_passwords(id)`
|
|
46
|
+
(metadata only; the secret is never returned). The mailbox *writes* are not
|
|
47
|
+
missing but unreachable — see Notes.
|
|
48
|
+
- **`projects.get()`** — the project the credential resolves to. Takes no id.
|
|
49
|
+
Carries `sandbox_address`, which no public route published before.
|
|
50
|
+
- **`domains.start_setup(id)`** — begins the guided DNS hand-off and returns the
|
|
51
|
+
route's own `{token, connectUrl, expiresAt}`. Finishing setup means a person
|
|
52
|
+
opening `connectUrl`, so the SDK hands back the link rather than modelling the
|
|
53
|
+
flow behind it.
|
|
54
|
+
|
|
55
|
+
### Notes
|
|
56
|
+
|
|
57
|
+
- **`send_v1` and `send_test_v1` never shipped.** They existed briefly on `main`
|
|
58
|
+
between 0.2.0 and this release as the additive step before the repoint, and
|
|
59
|
+
are folded into `send` and `send_test` here. If you installed from GitHub in
|
|
60
|
+
that window, rename the calls.
|
|
61
|
+
- **Some operations are permanently not SDK-callable.** Creating and deleting a
|
|
62
|
+
mailbox, creating and revoking an app password, the API-key operations, and
|
|
63
|
+
creating a project all resolve the acting user from a session and answer `401`
|
|
64
|
+
to any API key. They are recorded in `tests/test_contract.py`'s
|
|
65
|
+
`NOT_SDK_CALLABLE`, which the suite asserts equals the set the contract itself
|
|
66
|
+
declares — in both directions. Use the dashboard or an OAuth connection.
|
|
67
|
+
- **A project is capped at 10 mailboxes**, counting only ``PROVISIONING``,
|
|
68
|
+
``ACTIVE`` and ``SUSPENDED``. ``FAILED`` rows are excluded from the cap but
|
|
69
|
+
are still returned by ``mailboxes.list()``, so a project that has had failed
|
|
70
|
+
provisions can list more than 10 — the ``list()`` docstring said "at most 10"
|
|
71
|
+
without that distinction and now states it.
|
|
7
72
|
|
|
8
73
|
## [0.2.0]
|
|
9
74
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sendly-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0
|
|
4
4
|
Summary: Official Sendly Python SDK
|
|
5
5
|
Project-URL: Homepage, https://sendly.now
|
|
6
6
|
Project-URL: Documentation, https://docs.sendly.now
|
|
@@ -33,8 +33,8 @@ Description-Content-Type: text/markdown
|
|
|
33
33
|
# Sendly Python SDK
|
|
34
34
|
|
|
35
35
|
Official Python SDK for the [Sendly](https://sendly.now) REST API — transactional
|
|
36
|
-
email, contacts, events, domains, templates, email verification, webhooks,
|
|
37
|
-
suppression.
|
|
36
|
+
email, contacts, events, domains, templates, email verification, webhooks,
|
|
37
|
+
suppression, and mailbox and project reads.
|
|
38
38
|
|
|
39
39
|
[](https://github.com/DevinoSolutions/sendly-python/actions/workflows/ci.yml)
|
|
40
40
|
|
|
@@ -90,7 +90,7 @@ from sendly import Sendly
|
|
|
90
90
|
|
|
91
91
|
sendly = Sendly() # reads SENDLY_API_KEY
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
receipt = sendly.emails.send(
|
|
94
94
|
{
|
|
95
95
|
"from": "hello@yourdomain.com",
|
|
96
96
|
"to": "customer@example.com",
|
|
@@ -98,9 +98,35 @@ result = sendly.emails.send(
|
|
|
98
98
|
"body": "<h1>Thanks for signing up!</h1>",
|
|
99
99
|
}
|
|
100
100
|
)
|
|
101
|
-
|
|
101
|
+
|
|
102
|
+
# `status` is a real delivery state; poll `emails.get(receipt["id"])` for the
|
|
103
|
+
# events behind it.
|
|
104
|
+
print(receipt["id"], receipt["status"])
|
|
102
105
|
```
|
|
103
106
|
|
|
107
|
+
### Upgrading from 0.x
|
|
108
|
+
|
|
109
|
+
**1.0 repoints `emails.send` to the versioned `POST /api/v1/emails`.** It now
|
|
110
|
+
takes one recipient (`cc`/`bcc` copy others) and returns the `202` receipt
|
|
111
|
+
`{id, status, to, from}`, where `status` is a real delivery state. Before 1.0 it
|
|
112
|
+
posted to the legacy `POST /api/emails`, fanned an array `to` out to several
|
|
113
|
+
recipients, and returned `{emails, timestamp}` with no delivery status.
|
|
114
|
+
|
|
115
|
+
The old behaviour is kept, unchanged, as `emails.send_legacy`. Two ways to
|
|
116
|
+
upgrade:
|
|
117
|
+
|
|
118
|
+
- **Keep the old shapes:** rename the call. `send(...)` → `send_legacy(...)`.
|
|
119
|
+
Done.
|
|
120
|
+
- **Take the new default:** read the receipt instead of the envelope
|
|
121
|
+
(`receipt["id"]` / `receipt["status"]` in place of
|
|
122
|
+
`result["emails"][0]["email"]`), send to one recipient per call, and note that
|
|
123
|
+
failures now carry the v1 error fields (`err.error_code` is lowercase,
|
|
124
|
+
`err.request_id` and `err.field_errors` are set) — the exception classes are
|
|
125
|
+
the same, so `except` blocks stand.
|
|
126
|
+
|
|
127
|
+
Nothing else changed shape. See [CHANGELOG.md](./CHANGELOG.md) for the full
|
|
128
|
+
1.0.0 entry.
|
|
129
|
+
|
|
104
130
|
Or pass the key explicitly:
|
|
105
131
|
|
|
106
132
|
```python
|
|
@@ -134,9 +160,15 @@ with Sendly() as sendly:
|
|
|
134
160
|
### Emails
|
|
135
161
|
|
|
136
162
|
```python
|
|
137
|
-
# Single send (pass idempotency_key to dedupe replays for 24h)
|
|
138
|
-
|
|
139
|
-
|
|
163
|
+
# Single send on /api/v1 (pass idempotency_key to dedupe replays for 24h).
|
|
164
|
+
# One recipient in `to`; `cc` / `bcc` copy others. Returns {id, status, to, from}.
|
|
165
|
+
receipt = sendly.emails.send({"from": "a@you.com", "to": "b@them.com", "subject": "Hi", "body": "<p>Hi</p>"},
|
|
166
|
+
idempotency_key="order-42-receipt")
|
|
167
|
+
|
|
168
|
+
# The pre-1.0 send: legacy /api/emails, an array `to` fans out, answers
|
|
169
|
+
# {emails, timestamp} with no delivery status.
|
|
170
|
+
sendly.emails.send_legacy({"from": "a@you.com", "to": ["b@them.com", "c@them.com"],
|
|
171
|
+
"subject": "Hi", "body": "<p>Hi</p>"})
|
|
140
172
|
|
|
141
173
|
# Batch send (up to 100)
|
|
142
174
|
sendly.emails.batch({"emails": [{"from": "a@you.com", "to": "b@them.com", "subject": "Hi", "body": "<p>Hi</p>"}]})
|
|
@@ -180,9 +212,45 @@ sendly.domains.list()
|
|
|
180
212
|
sendly.domains.get("d_123")
|
|
181
213
|
sendly.domains.verify("d_123")
|
|
182
214
|
sendly.domains.get_verification("d_123")
|
|
215
|
+
sendly.domains.start_setup("d_123") # -> {"token", "connectUrl", "expiresAt"}
|
|
183
216
|
sendly.domains.delete("d_123")
|
|
184
217
|
```
|
|
185
218
|
|
|
219
|
+
`start_setup` returns the hand-off as the API returns it. Open `connectUrl` in a
|
|
220
|
+
browser to finish DNS setup at the registrar.
|
|
221
|
+
|
|
222
|
+
### Mailboxes
|
|
223
|
+
|
|
224
|
+
Reads only — see [What the SDK does not expose](#what-the-sdk-does-not-expose).
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
sendly.mailboxes.list() # -> [mailbox, ...], not paginated
|
|
228
|
+
detail = sendly.mailboxes.get("mb_123")
|
|
229
|
+
|
|
230
|
+
# `settings` carries the IMAP and SMTP host, port, security and username.
|
|
231
|
+
print(detail["settings"]["imap"]["host"], detail["settings"]["imap"]["port"])
|
|
232
|
+
|
|
233
|
+
# Metadata only — `lastFour` is the one fragment of the secret that survives
|
|
234
|
+
# creation, so a credential can be identified but not rebuilt.
|
|
235
|
+
for pw in sendly.mailboxes.list_app_passwords("mb_123"):
|
|
236
|
+
print(pw["name"], pw["lastFour"], pw["lastUsedAt"])
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
This lists the mailboxes themselves, never their contents — received messages
|
|
240
|
+
are not part of the public API. The mailbox **password** is never returned by
|
|
241
|
+
any of these reads; mailbox credentials are app passwords, created from the
|
|
242
|
+
dashboard and shown once. `list_app_passwords` returns only the passwords that
|
|
243
|
+
are still active — a revoked one drops out, so this is not an audit history.
|
|
244
|
+
|
|
245
|
+
**The per-project cap is 10 mailboxes.** It counts only those holding, or
|
|
246
|
+
mid-way to holding, a real account — `PROVISIONING`, `ACTIVE` and `SUSPENDED`.
|
|
247
|
+
`FAILED` rows are excluded on purpose, so that a burst of failed provisions
|
|
248
|
+
cannot eat a project's allowance and turn an outage into "you have reached your
|
|
249
|
+
mailbox limit"; they are still returned by `list()`, so a project that has had
|
|
250
|
+
failures can list more than 10. Exceeding the cap is a `409`
|
|
251
|
+
(`SendlyConflictError`) from whatever creates the mailbox — which is not this
|
|
252
|
+
SDK, since mailbox creation needs a signed-in user.
|
|
253
|
+
|
|
186
254
|
### Templates
|
|
187
255
|
|
|
188
256
|
```python
|
|
@@ -316,7 +384,7 @@ Available on the six cursor-paginated listings: `campaigns.iter_list`,
|
|
|
316
384
|
`events.list_names` / `events.stats` return a bounded aggregate rather than a
|
|
317
385
|
cursor, so they have no iterator.
|
|
318
386
|
|
|
319
|
-
### Segments, workflows, events, analytics, usage
|
|
387
|
+
### Segments, workflows, events, analytics, usage, projects
|
|
320
388
|
|
|
321
389
|
```python
|
|
322
390
|
segment = sendly.segments.create({"name": "Power users", "type": "DYNAMIC",
|
|
@@ -343,8 +411,71 @@ sendly.analytics.top_campaigns({"limit": 5})
|
|
|
343
411
|
|
|
344
412
|
usage = sendly.usage.get()
|
|
345
413
|
print(usage["plan"], usage["monthly"])
|
|
414
|
+
|
|
415
|
+
project = sendly.projects.get()
|
|
416
|
+
print(project["sandbox_address"])
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
### Emails: `send` vs `send_legacy`
|
|
420
|
+
|
|
421
|
+
The same split as `events.track` / `events.record`, resolved the other way
|
|
422
|
+
round: since 1.0, `emails.send` IS the versioned send. It posts to
|
|
423
|
+
`/api/v1/emails` and answers `202` with `{id, status, to, from}`, where `status`
|
|
424
|
+
is a real delivery state you can poll on. It takes one recipient — use
|
|
425
|
+
`cc`/`bcc` to copy others — instead of fanning an array out.
|
|
426
|
+
`emails.send_legacy` is the pre-1.0 send on `POST /api/emails`, unchanged: row
|
|
427
|
+
ids, **no delivery status**, array `to` fanned out. See
|
|
428
|
+
[Upgrading from 0.x](#upgrading-from-0x).
|
|
429
|
+
|
|
430
|
+
```python
|
|
431
|
+
receipt = sendly.emails.send(
|
|
432
|
+
{"to": "user@example.com", "subject": "hi", "body": "<p>hi</p>"},
|
|
433
|
+
idempotency_key="order-42",
|
|
434
|
+
)
|
|
435
|
+
print(receipt["status"])
|
|
346
436
|
```
|
|
347
437
|
|
|
438
|
+
### Test sends
|
|
439
|
+
|
|
440
|
+
`emails.send_test` proves the send path works without touching a live
|
|
441
|
+
recipient. Two things about it are easy to get backwards:
|
|
442
|
+
|
|
443
|
+
- **The sandbox address is the *sender*, not the destination.** It is resolved
|
|
444
|
+
server-side, and naming a `from` yourself is **refused** rather than ignored —
|
|
445
|
+
so a request expecting a different sender never gets a success it would
|
|
446
|
+
misread. `projects.get()["sandbox_address"]` tells you what it sends *from*;
|
|
447
|
+
the response's `from` says the same thing.
|
|
448
|
+
- **It lands in the project owner's own inbox.** `to` is optional and defaults
|
|
449
|
+
to the project owner's verified account email, which is the only address a
|
|
450
|
+
sandbox send may reach — any other value is refused.
|
|
451
|
+
|
|
452
|
+
```python
|
|
453
|
+
test = sendly.emails.send_test({"subject": "hi", "body": "<p>hi</p>"})
|
|
454
|
+
print(test["to"], test["from"], test["sandbox"]) # sandbox is always True here
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
Everything else applies unchanged: the same rendering, the same content scan,
|
|
458
|
+
and the same daily and trust-tier caps as a real send. It takes no
|
|
459
|
+
`idempotency_key` — the recipient is the caller's own inbox, a daily cap already
|
|
460
|
+
bounds it, and "send me another one" is the normal second call rather than a
|
|
461
|
+
mistake worth deduplicating.
|
|
462
|
+
|
|
463
|
+
### What the SDK does not expose
|
|
464
|
+
|
|
465
|
+
An API key resolves no user, and a handful of routes resolve the acting project
|
|
466
|
+
admin from the session before reading any scope — so they answer `401` to any
|
|
467
|
+
key, however broad its scopes. The contract states this: those operations publish
|
|
468
|
+
`SessionAuth` without `ApiKeyAuth`.
|
|
469
|
+
|
|
470
|
+
Rather than ship methods that could never succeed, they are listed in
|
|
471
|
+
`tests/test_contract.py`'s `NOT_SDK_CALLABLE` and checked against the spec's own
|
|
472
|
+
declarations, in both directions. They are: creating and deleting a mailbox,
|
|
473
|
+
creating and revoking an app password, all four API-key operations, and creating
|
|
474
|
+
a project. Use the dashboard or an OAuth connection for those.
|
|
475
|
+
|
|
476
|
+
Mailbox **reads** are exposed — their membership check is conditional, so a key
|
|
477
|
+
really can call them.
|
|
478
|
+
|
|
348
479
|
## Error handling
|
|
349
480
|
|
|
350
481
|
Every non-2xx response raises a `SendlyError` subclass carrying `status_code`,
|
|
@@ -456,7 +587,7 @@ comparison and reject a stale or non-numeric timestamp.
|
|
|
456
587
|
|
|
457
588
|
## Async
|
|
458
589
|
|
|
459
|
-
Only a synchronous client ships
|
|
590
|
+
Only a synchronous client ships today. An `httpx.AsyncClient`-backed async
|
|
460
591
|
variant is planned.
|
|
461
592
|
|
|
462
593
|
## Development
|
|
@@ -474,6 +605,45 @@ pytest
|
|
|
474
605
|
|
|
475
606
|
Tests are fully hermetic (httpx `MockTransport`) and hit no network.
|
|
476
607
|
|
|
608
|
+
### Refreshing the vendored OpenAPI spec
|
|
609
|
+
|
|
610
|
+
`tests/fixtures/openapi.json` is a committed snapshot of Sendly's OpenAPI
|
|
611
|
+
contract; the contract suite (`tests/test_contract.py`) verifies the SDK surface
|
|
612
|
+
against it and never touches the network.
|
|
613
|
+
|
|
614
|
+
`scripts/sync_spec.py` requires `SENDLY_OPENAPI_URL`. There is **no default**,
|
|
615
|
+
and in particular it does not default to production:
|
|
616
|
+
|
|
617
|
+
```bash
|
|
618
|
+
SENDLY_OPENAPI_URL=/path/to/sendly/apps/web/openapi/openapi.json \
|
|
619
|
+
python scripts/sync_spec.py
|
|
620
|
+
|
|
621
|
+
SENDLY_OPENAPI_URL=... python scripts/sync_spec.py --check # is the copy stale?
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
`SENDLY_OPENAPI_URL` accepts a filesystem path (the normal case — the committed
|
|
625
|
+
contract in the Sendly platform monorepo at `apps/web/openapi/openapi.json`) or
|
|
626
|
+
an `http(s)://` URL of a local or staging API. Running the script with it unset
|
|
627
|
+
exits non-zero and prints what to set.
|
|
628
|
+
|
|
629
|
+
**Do not point it at `https://api.sendly.now`.** Vendoring the spec from the
|
|
630
|
+
deployed API makes the SDK mirror what is *running* rather than what the repo
|
|
631
|
+
*declares*, so any drift between the platform's code and its committed contract
|
|
632
|
+
is laundered into "correct" on the way in — the SDK re-vendors to match the
|
|
633
|
+
deployment and the mismatch vanishes silently. That destroys the vendored spec's
|
|
634
|
+
only job: it is the fixed reference `tests/test_contract.py` compares against, so
|
|
635
|
+
an SDK synced from production can no longer detect the very drift it exists to
|
|
636
|
+
catch. It is also unreproducible and unreviewable.
|
|
637
|
+
|
|
638
|
+
This is not hard-blocked — "what does production actually serve?" is a legitimate
|
|
639
|
+
one-off. Doing it prints an unmissable warning (and a CI annotation), because
|
|
640
|
+
*quiet* is what made the old default dangerous, not the host. Never commit the
|
|
641
|
+
result, and never wire that host into CI or any unattended job.
|
|
642
|
+
|
|
643
|
+
`--check` is the exception to the fail-loud rule: it never runs unattended
|
|
644
|
+
against an unknown source, so with `SENDLY_OPENAPI_URL` unset it skips with a
|
|
645
|
+
notice and exits 0, keeping CI and fork pull requests green.
|
|
646
|
+
|
|
477
647
|
## Documentation
|
|
478
648
|
|
|
479
649
|
Full API reference: <https://docs.sendly.now>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Sendly Python SDK
|
|
2
2
|
|
|
3
3
|
Official Python SDK for the [Sendly](https://sendly.now) REST API — transactional
|
|
4
|
-
email, contacts, events, domains, templates, email verification, webhooks,
|
|
5
|
-
suppression.
|
|
4
|
+
email, contacts, events, domains, templates, email verification, webhooks,
|
|
5
|
+
suppression, and mailbox and project reads.
|
|
6
6
|
|
|
7
7
|
[](https://github.com/DevinoSolutions/sendly-python/actions/workflows/ci.yml)
|
|
8
8
|
|
|
@@ -58,7 +58,7 @@ from sendly import Sendly
|
|
|
58
58
|
|
|
59
59
|
sendly = Sendly() # reads SENDLY_API_KEY
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
receipt = sendly.emails.send(
|
|
62
62
|
{
|
|
63
63
|
"from": "hello@yourdomain.com",
|
|
64
64
|
"to": "customer@example.com",
|
|
@@ -66,9 +66,35 @@ result = sendly.emails.send(
|
|
|
66
66
|
"body": "<h1>Thanks for signing up!</h1>",
|
|
67
67
|
}
|
|
68
68
|
)
|
|
69
|
-
|
|
69
|
+
|
|
70
|
+
# `status` is a real delivery state; poll `emails.get(receipt["id"])` for the
|
|
71
|
+
# events behind it.
|
|
72
|
+
print(receipt["id"], receipt["status"])
|
|
70
73
|
```
|
|
71
74
|
|
|
75
|
+
### Upgrading from 0.x
|
|
76
|
+
|
|
77
|
+
**1.0 repoints `emails.send` to the versioned `POST /api/v1/emails`.** It now
|
|
78
|
+
takes one recipient (`cc`/`bcc` copy others) and returns the `202` receipt
|
|
79
|
+
`{id, status, to, from}`, where `status` is a real delivery state. Before 1.0 it
|
|
80
|
+
posted to the legacy `POST /api/emails`, fanned an array `to` out to several
|
|
81
|
+
recipients, and returned `{emails, timestamp}` with no delivery status.
|
|
82
|
+
|
|
83
|
+
The old behaviour is kept, unchanged, as `emails.send_legacy`. Two ways to
|
|
84
|
+
upgrade:
|
|
85
|
+
|
|
86
|
+
- **Keep the old shapes:** rename the call. `send(...)` → `send_legacy(...)`.
|
|
87
|
+
Done.
|
|
88
|
+
- **Take the new default:** read the receipt instead of the envelope
|
|
89
|
+
(`receipt["id"]` / `receipt["status"]` in place of
|
|
90
|
+
`result["emails"][0]["email"]`), send to one recipient per call, and note that
|
|
91
|
+
failures now carry the v1 error fields (`err.error_code` is lowercase,
|
|
92
|
+
`err.request_id` and `err.field_errors` are set) — the exception classes are
|
|
93
|
+
the same, so `except` blocks stand.
|
|
94
|
+
|
|
95
|
+
Nothing else changed shape. See [CHANGELOG.md](./CHANGELOG.md) for the full
|
|
96
|
+
1.0.0 entry.
|
|
97
|
+
|
|
72
98
|
Or pass the key explicitly:
|
|
73
99
|
|
|
74
100
|
```python
|
|
@@ -102,9 +128,15 @@ with Sendly() as sendly:
|
|
|
102
128
|
### Emails
|
|
103
129
|
|
|
104
130
|
```python
|
|
105
|
-
# Single send (pass idempotency_key to dedupe replays for 24h)
|
|
106
|
-
|
|
107
|
-
|
|
131
|
+
# Single send on /api/v1 (pass idempotency_key to dedupe replays for 24h).
|
|
132
|
+
# One recipient in `to`; `cc` / `bcc` copy others. Returns {id, status, to, from}.
|
|
133
|
+
receipt = sendly.emails.send({"from": "a@you.com", "to": "b@them.com", "subject": "Hi", "body": "<p>Hi</p>"},
|
|
134
|
+
idempotency_key="order-42-receipt")
|
|
135
|
+
|
|
136
|
+
# The pre-1.0 send: legacy /api/emails, an array `to` fans out, answers
|
|
137
|
+
# {emails, timestamp} with no delivery status.
|
|
138
|
+
sendly.emails.send_legacy({"from": "a@you.com", "to": ["b@them.com", "c@them.com"],
|
|
139
|
+
"subject": "Hi", "body": "<p>Hi</p>"})
|
|
108
140
|
|
|
109
141
|
# Batch send (up to 100)
|
|
110
142
|
sendly.emails.batch({"emails": [{"from": "a@you.com", "to": "b@them.com", "subject": "Hi", "body": "<p>Hi</p>"}]})
|
|
@@ -148,9 +180,45 @@ sendly.domains.list()
|
|
|
148
180
|
sendly.domains.get("d_123")
|
|
149
181
|
sendly.domains.verify("d_123")
|
|
150
182
|
sendly.domains.get_verification("d_123")
|
|
183
|
+
sendly.domains.start_setup("d_123") # -> {"token", "connectUrl", "expiresAt"}
|
|
151
184
|
sendly.domains.delete("d_123")
|
|
152
185
|
```
|
|
153
186
|
|
|
187
|
+
`start_setup` returns the hand-off as the API returns it. Open `connectUrl` in a
|
|
188
|
+
browser to finish DNS setup at the registrar.
|
|
189
|
+
|
|
190
|
+
### Mailboxes
|
|
191
|
+
|
|
192
|
+
Reads only — see [What the SDK does not expose](#what-the-sdk-does-not-expose).
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
sendly.mailboxes.list() # -> [mailbox, ...], not paginated
|
|
196
|
+
detail = sendly.mailboxes.get("mb_123")
|
|
197
|
+
|
|
198
|
+
# `settings` carries the IMAP and SMTP host, port, security and username.
|
|
199
|
+
print(detail["settings"]["imap"]["host"], detail["settings"]["imap"]["port"])
|
|
200
|
+
|
|
201
|
+
# Metadata only — `lastFour` is the one fragment of the secret that survives
|
|
202
|
+
# creation, so a credential can be identified but not rebuilt.
|
|
203
|
+
for pw in sendly.mailboxes.list_app_passwords("mb_123"):
|
|
204
|
+
print(pw["name"], pw["lastFour"], pw["lastUsedAt"])
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
This lists the mailboxes themselves, never their contents — received messages
|
|
208
|
+
are not part of the public API. The mailbox **password** is never returned by
|
|
209
|
+
any of these reads; mailbox credentials are app passwords, created from the
|
|
210
|
+
dashboard and shown once. `list_app_passwords` returns only the passwords that
|
|
211
|
+
are still active — a revoked one drops out, so this is not an audit history.
|
|
212
|
+
|
|
213
|
+
**The per-project cap is 10 mailboxes.** It counts only those holding, or
|
|
214
|
+
mid-way to holding, a real account — `PROVISIONING`, `ACTIVE` and `SUSPENDED`.
|
|
215
|
+
`FAILED` rows are excluded on purpose, so that a burst of failed provisions
|
|
216
|
+
cannot eat a project's allowance and turn an outage into "you have reached your
|
|
217
|
+
mailbox limit"; they are still returned by `list()`, so a project that has had
|
|
218
|
+
failures can list more than 10. Exceeding the cap is a `409`
|
|
219
|
+
(`SendlyConflictError`) from whatever creates the mailbox — which is not this
|
|
220
|
+
SDK, since mailbox creation needs a signed-in user.
|
|
221
|
+
|
|
154
222
|
### Templates
|
|
155
223
|
|
|
156
224
|
```python
|
|
@@ -284,7 +352,7 @@ Available on the six cursor-paginated listings: `campaigns.iter_list`,
|
|
|
284
352
|
`events.list_names` / `events.stats` return a bounded aggregate rather than a
|
|
285
353
|
cursor, so they have no iterator.
|
|
286
354
|
|
|
287
|
-
### Segments, workflows, events, analytics, usage
|
|
355
|
+
### Segments, workflows, events, analytics, usage, projects
|
|
288
356
|
|
|
289
357
|
```python
|
|
290
358
|
segment = sendly.segments.create({"name": "Power users", "type": "DYNAMIC",
|
|
@@ -311,8 +379,71 @@ sendly.analytics.top_campaigns({"limit": 5})
|
|
|
311
379
|
|
|
312
380
|
usage = sendly.usage.get()
|
|
313
381
|
print(usage["plan"], usage["monthly"])
|
|
382
|
+
|
|
383
|
+
project = sendly.projects.get()
|
|
384
|
+
print(project["sandbox_address"])
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
### Emails: `send` vs `send_legacy`
|
|
388
|
+
|
|
389
|
+
The same split as `events.track` / `events.record`, resolved the other way
|
|
390
|
+
round: since 1.0, `emails.send` IS the versioned send. It posts to
|
|
391
|
+
`/api/v1/emails` and answers `202` with `{id, status, to, from}`, where `status`
|
|
392
|
+
is a real delivery state you can poll on. It takes one recipient — use
|
|
393
|
+
`cc`/`bcc` to copy others — instead of fanning an array out.
|
|
394
|
+
`emails.send_legacy` is the pre-1.0 send on `POST /api/emails`, unchanged: row
|
|
395
|
+
ids, **no delivery status**, array `to` fanned out. See
|
|
396
|
+
[Upgrading from 0.x](#upgrading-from-0x).
|
|
397
|
+
|
|
398
|
+
```python
|
|
399
|
+
receipt = sendly.emails.send(
|
|
400
|
+
{"to": "user@example.com", "subject": "hi", "body": "<p>hi</p>"},
|
|
401
|
+
idempotency_key="order-42",
|
|
402
|
+
)
|
|
403
|
+
print(receipt["status"])
|
|
314
404
|
```
|
|
315
405
|
|
|
406
|
+
### Test sends
|
|
407
|
+
|
|
408
|
+
`emails.send_test` proves the send path works without touching a live
|
|
409
|
+
recipient. Two things about it are easy to get backwards:
|
|
410
|
+
|
|
411
|
+
- **The sandbox address is the *sender*, not the destination.** It is resolved
|
|
412
|
+
server-side, and naming a `from` yourself is **refused** rather than ignored —
|
|
413
|
+
so a request expecting a different sender never gets a success it would
|
|
414
|
+
misread. `projects.get()["sandbox_address"]` tells you what it sends *from*;
|
|
415
|
+
the response's `from` says the same thing.
|
|
416
|
+
- **It lands in the project owner's own inbox.** `to` is optional and defaults
|
|
417
|
+
to the project owner's verified account email, which is the only address a
|
|
418
|
+
sandbox send may reach — any other value is refused.
|
|
419
|
+
|
|
420
|
+
```python
|
|
421
|
+
test = sendly.emails.send_test({"subject": "hi", "body": "<p>hi</p>"})
|
|
422
|
+
print(test["to"], test["from"], test["sandbox"]) # sandbox is always True here
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
Everything else applies unchanged: the same rendering, the same content scan,
|
|
426
|
+
and the same daily and trust-tier caps as a real send. It takes no
|
|
427
|
+
`idempotency_key` — the recipient is the caller's own inbox, a daily cap already
|
|
428
|
+
bounds it, and "send me another one" is the normal second call rather than a
|
|
429
|
+
mistake worth deduplicating.
|
|
430
|
+
|
|
431
|
+
### What the SDK does not expose
|
|
432
|
+
|
|
433
|
+
An API key resolves no user, and a handful of routes resolve the acting project
|
|
434
|
+
admin from the session before reading any scope — so they answer `401` to any
|
|
435
|
+
key, however broad its scopes. The contract states this: those operations publish
|
|
436
|
+
`SessionAuth` without `ApiKeyAuth`.
|
|
437
|
+
|
|
438
|
+
Rather than ship methods that could never succeed, they are listed in
|
|
439
|
+
`tests/test_contract.py`'s `NOT_SDK_CALLABLE` and checked against the spec's own
|
|
440
|
+
declarations, in both directions. They are: creating and deleting a mailbox,
|
|
441
|
+
creating and revoking an app password, all four API-key operations, and creating
|
|
442
|
+
a project. Use the dashboard or an OAuth connection for those.
|
|
443
|
+
|
|
444
|
+
Mailbox **reads** are exposed — their membership check is conditional, so a key
|
|
445
|
+
really can call them.
|
|
446
|
+
|
|
316
447
|
## Error handling
|
|
317
448
|
|
|
318
449
|
Every non-2xx response raises a `SendlyError` subclass carrying `status_code`,
|
|
@@ -424,7 +555,7 @@ comparison and reject a stale or non-numeric timestamp.
|
|
|
424
555
|
|
|
425
556
|
## Async
|
|
426
557
|
|
|
427
|
-
Only a synchronous client ships
|
|
558
|
+
Only a synchronous client ships today. An `httpx.AsyncClient`-backed async
|
|
428
559
|
variant is planned.
|
|
429
560
|
|
|
430
561
|
## Development
|
|
@@ -442,6 +573,45 @@ pytest
|
|
|
442
573
|
|
|
443
574
|
Tests are fully hermetic (httpx `MockTransport`) and hit no network.
|
|
444
575
|
|
|
576
|
+
### Refreshing the vendored OpenAPI spec
|
|
577
|
+
|
|
578
|
+
`tests/fixtures/openapi.json` is a committed snapshot of Sendly's OpenAPI
|
|
579
|
+
contract; the contract suite (`tests/test_contract.py`) verifies the SDK surface
|
|
580
|
+
against it and never touches the network.
|
|
581
|
+
|
|
582
|
+
`scripts/sync_spec.py` requires `SENDLY_OPENAPI_URL`. There is **no default**,
|
|
583
|
+
and in particular it does not default to production:
|
|
584
|
+
|
|
585
|
+
```bash
|
|
586
|
+
SENDLY_OPENAPI_URL=/path/to/sendly/apps/web/openapi/openapi.json \
|
|
587
|
+
python scripts/sync_spec.py
|
|
588
|
+
|
|
589
|
+
SENDLY_OPENAPI_URL=... python scripts/sync_spec.py --check # is the copy stale?
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
`SENDLY_OPENAPI_URL` accepts a filesystem path (the normal case — the committed
|
|
593
|
+
contract in the Sendly platform monorepo at `apps/web/openapi/openapi.json`) or
|
|
594
|
+
an `http(s)://` URL of a local or staging API. Running the script with it unset
|
|
595
|
+
exits non-zero and prints what to set.
|
|
596
|
+
|
|
597
|
+
**Do not point it at `https://api.sendly.now`.** Vendoring the spec from the
|
|
598
|
+
deployed API makes the SDK mirror what is *running* rather than what the repo
|
|
599
|
+
*declares*, so any drift between the platform's code and its committed contract
|
|
600
|
+
is laundered into "correct" on the way in — the SDK re-vendors to match the
|
|
601
|
+
deployment and the mismatch vanishes silently. That destroys the vendored spec's
|
|
602
|
+
only job: it is the fixed reference `tests/test_contract.py` compares against, so
|
|
603
|
+
an SDK synced from production can no longer detect the very drift it exists to
|
|
604
|
+
catch. It is also unreproducible and unreviewable.
|
|
605
|
+
|
|
606
|
+
This is not hard-blocked — "what does production actually serve?" is a legitimate
|
|
607
|
+
one-off. Doing it prints an unmissable warning (and a CI annotation), because
|
|
608
|
+
*quiet* is what made the old default dangerous, not the host. Never commit the
|
|
609
|
+
result, and never wire that host into CI or any unattended job.
|
|
610
|
+
|
|
611
|
+
`--check` is the exception to the fail-loud rule: it never runs unattended
|
|
612
|
+
against an unknown source, so with `SENDLY_OPENAPI_URL` unset it skips with a
|
|
613
|
+
notice and exits 0, keeping CI and fork pull requests green.
|
|
614
|
+
|
|
445
615
|
## Documentation
|
|
446
616
|
|
|
447
617
|
Full API reference: <https://docs.sendly.now>
|
|
@@ -8,7 +8,7 @@ build-backend = "hatchling.build"
|
|
|
8
8
|
# the PyPI trusted-publisher configuration). The import package is
|
|
9
9
|
# unchanged (`import sendly`) — see [tool.hatch.build.targets.wheel] below.
|
|
10
10
|
name = "sendly-python"
|
|
11
|
-
version = "0.
|
|
11
|
+
version = "1.0.0"
|
|
12
12
|
description = "Official Sendly Python SDK"
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
requires-python = ">=3.10"
|