sendly-python 1.0.0__tar.gz → 1.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-1.1.0/CHANGELOG.md +459 -0
- sendly_python-1.1.0/PKG-INFO +1323 -0
- sendly_python-1.1.0/README.md +1291 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/pyproject.toml +1 -1
- {sendly_python-1.0.0 → sendly_python-1.1.0}/src/sendly/__init__.py +8 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/src/sendly/client.py +9 -1
- {sendly_python-1.0.0 → sendly_python-1.1.0}/src/sendly/resources/_pagination.py +6 -3
- {sendly_python-1.0.0 → sendly_python-1.1.0}/src/sendly/resources/campaigns.py +43 -0
- sendly_python-1.1.0/src/sendly/resources/contacts.py +191 -0
- sendly_python-1.1.0/src/sendly/resources/deliverability.py +109 -0
- sendly_python-1.1.0/src/sendly/resources/domains.py +210 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/src/sendly/resources/emails.py +20 -8
- {sendly_python-1.0.0 → sendly_python-1.1.0}/src/sendly/resources/events.py +3 -1
- sendly_python-1.1.0/src/sendly/resources/lists.py +152 -0
- sendly_python-1.1.0/src/sendly/resources/mailboxes.py +133 -0
- sendly_python-1.1.0/src/sendly/resources/snippets.py +72 -0
- sendly_python-1.1.0/src/sendly/resources/suppression.py +131 -0
- sendly_python-1.1.0/src/sendly/resources/templates.py +147 -0
- sendly_python-1.1.0/src/sendly/resources/topics.py +109 -0
- sendly_python-1.1.0/src/sendly/resources/validation.py +99 -0
- sendly_python-1.1.0/src/sendly/resources/webhooks.py +188 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/src/sendly/resources/workflows.py +94 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/src/sendly/types.py +94 -1
- {sendly_python-1.0.0 → sendly_python-1.1.0}/tests/fixtures/openapi.json +15964 -5448
- {sendly_python-1.0.0 → sendly_python-1.1.0}/tests/support.py +8 -4
- {sendly_python-1.0.0 → sendly_python-1.1.0}/tests/test_campaigns.py +88 -0
- sendly_python-1.1.0/tests/test_contacts.py +220 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/tests/test_contract.py +11 -3
- sendly_python-1.1.0/tests/test_deliverability.py +153 -0
- sendly_python-1.1.0/tests/test_domains.py +255 -0
- sendly_python-1.1.0/tests/test_lists.py +255 -0
- sendly_python-1.1.0/tests/test_mailboxes.py +197 -0
- sendly_python-1.1.0/tests/test_snippets.py +103 -0
- sendly_python-1.1.0/tests/test_suppression.py +158 -0
- sendly_python-1.1.0/tests/test_templates.py +184 -0
- sendly_python-1.1.0/tests/test_topics.py +152 -0
- sendly_python-1.1.0/tests/test_validation.py +162 -0
- sendly_python-1.1.0/tests/test_webhooks.py +186 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/tests/test_workflows.py +109 -1
- sendly_python-1.0.0/CHANGELOG.md +0 -138
- sendly_python-1.0.0/PKG-INFO +0 -653
- sendly_python-1.0.0/README.md +0 -621
- sendly_python-1.0.0/src/sendly/resources/contacts.py +0 -93
- sendly_python-1.0.0/src/sendly/resources/domains.py +0 -83
- sendly_python-1.0.0/src/sendly/resources/lists.py +0 -59
- sendly_python-1.0.0/src/sendly/resources/mailboxes.py +0 -75
- sendly_python-1.0.0/src/sendly/resources/suppression.py +0 -52
- sendly_python-1.0.0/src/sendly/resources/templates.py +0 -61
- sendly_python-1.0.0/src/sendly/resources/webhooks.py +0 -76
- sendly_python-1.0.0/tests/test_contacts.py +0 -97
- sendly_python-1.0.0/tests/test_domains.py +0 -70
- sendly_python-1.0.0/tests/test_lists.py +0 -123
- sendly_python-1.0.0/tests/test_mailboxes.py +0 -99
- sendly_python-1.0.0/tests/test_suppression.py +0 -49
- sendly_python-1.0.0/tests/test_templates.py +0 -56
- sendly_python-1.0.0/tests/test_webhooks.py +0 -66
- {sendly_python-1.0.0 → sendly_python-1.1.0}/.github/workflows/ci.yml +0 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/.github/workflows/release.yml +0 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/.gitignore +0 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/LICENSE +0 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/scripts/sync_spec.py +0 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/src/sendly/errors.py +0 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/src/sendly/py.typed +0 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/src/sendly/resources/__init__.py +0 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/src/sendly/resources/_helpers.py +0 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/src/sendly/resources/analytics.py +0 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/src/sendly/resources/projects.py +0 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/src/sendly/resources/segments.py +0 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/src/sendly/resources/usage.py +0 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/src/sendly/resources/verify.py +0 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/src/sendly/webhook_utils.py +0 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/tests/test_analytics.py +0 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/tests/test_client.py +0 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/tests/test_emails.py +0 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/tests/test_errors_problem.py +0 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/tests/test_events.py +0 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/tests/test_projects.py +0 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/tests/test_segments.py +0 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/tests/test_usage.py +0 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/tests/test_verify.py +0 -0
- {sendly_python-1.0.0 → sendly_python-1.1.0}/tests/test_webhook_verify.py +0 -0
|
@@ -0,0 +1,459 @@
|
|
|
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
|
+
## [1.1.0] - 2026-09-05
|
|
7
|
+
|
|
8
|
+
Four new resources, the `/api/v1` half of six that only had a legacy one, and a
|
|
9
|
+
set of renames the platform made on the wire. Most of this release is additive,
|
|
10
|
+
but the renames are breaking, so it is a major-in-spirit minor: 1.1 talks to an
|
|
11
|
+
API that 1.0 did not.
|
|
12
|
+
|
|
13
|
+
> **The platform deploy this release waited on has shipped** — monorepo commit
|
|
14
|
+
> `57826bad`, deployed 2026-09-06 — so 1.1 is releasable. Anyone still running
|
|
15
|
+
> the pre-`57826bad` platform should stay on 1.0: an SDK sending `emailCategory`
|
|
16
|
+
> at an API that still expects `type` is answered `422 validation_error` on every
|
|
17
|
+
> template and campaign write.
|
|
18
|
+
>
|
|
19
|
+
> The vendored `tests/fixtures/openapi.json` is that released contract byte for
|
|
20
|
+
> byte, taken from the monorepo at `57826bad` rather than synced from the
|
|
21
|
+
> deployed API — see `scripts/sync_spec.py` for why production is never the
|
|
22
|
+
> source.
|
|
23
|
+
|
|
24
|
+
### Breaking
|
|
25
|
+
|
|
26
|
+
- **`Template.type` and `Campaign.type` are now `emailCategory`** on the legacy
|
|
27
|
+
dialect and `email_category` on v1. It affects `templates.create`,
|
|
28
|
+
`templates.update`, the `emailCategory` filter on `templates.list`, and
|
|
29
|
+
`campaigns.create`. Rename the key in the body; the values are unchanged
|
|
30
|
+
except that the enum member **`HEADLESS` is now `SELF_MANAGED_UNSUBSCRIBE`** —
|
|
31
|
+
the old name said how the mail was built, the new one says what the recipient
|
|
32
|
+
gets, which is the fact a caller is choosing between.
|
|
33
|
+
|
|
34
|
+
Nothing is accepted under both names, deliberately: an alias would let a
|
|
35
|
+
half-migrated codebase keep working while the two spellings drifted apart.
|
|
36
|
+
|
|
37
|
+
- **`events.record`'s payload field is now `payload`, not `data`.** Only the v1
|
|
38
|
+
write moved. **`events.track` is unaffected** and still takes `data`, because
|
|
39
|
+
it is the legacy `POST /api/track` and its body is a different schema that was
|
|
40
|
+
not part of this rename. The SDK documents what each endpoint actually accepts
|
|
41
|
+
rather than smoothing the two together — a shared name here would be a lie
|
|
42
|
+
about one of them.
|
|
43
|
+
|
|
44
|
+
- **`Domain.mailFromStatus` is now `mailFromDomainStatus`** (and
|
|
45
|
+
`mail_from_domain_status` on the v1 document). It sits beside `mailFromDomain`
|
|
46
|
+
and is the status *of that domain*, which the old name did not say.
|
|
47
|
+
|
|
48
|
+
- **`emails.get` returns a different body — read this one.** It used to hand
|
|
49
|
+
back the whole database row plus an `events` array that was the **wrong
|
|
50
|
+
relation**: the custom analytics events a caller records with `events.record`,
|
|
51
|
+
not the delivery history the operation has always promised. A caller polling
|
|
52
|
+
it for delivery state was reading somebody else's data and, if their project
|
|
53
|
+
recorded no custom events, an empty list that looked like "nothing has
|
|
54
|
+
happened yet".
|
|
55
|
+
|
|
56
|
+
It now returns an explicit field list, `events` as the delivery timeline
|
|
57
|
+
(oldest first), and `to` filled from the joined contact — a field the spec had
|
|
58
|
+
always declared and the response had never carried.
|
|
59
|
+
|
|
60
|
+
Keys that used to leak out of it and no longer do: `bodyHash`, `dedupKey`,
|
|
61
|
+
`idempotencyKey`, `linkMap`, `sesMessageId`, `sesInboundMessageId`, `body` and
|
|
62
|
+
`headers`. Four of those are ledger keys for deduplication and idempotency and
|
|
63
|
+
the rest are internal routing state or the rendered message; none was ever
|
|
64
|
+
documented. What to change: read `events.list` if you wanted custom events,
|
|
65
|
+
and keep your own copy of the body if you were reading it back out of here.
|
|
66
|
+
|
|
67
|
+
- **`emails.list` and `emails.cancel_schedule` narrowed the same way.** All three
|
|
68
|
+
handlers on that surface were returning the whole database row and each had got
|
|
69
|
+
there separately; they share one field list now. The list was the widest of
|
|
70
|
+
them, since it leaked a page of rows at a time, and `cancel_schedule` returned
|
|
71
|
+
the `dedupKey` in the same response that released it. The same eight fields
|
|
72
|
+
named above are gone from both, and both now carry `to`.
|
|
73
|
+
|
|
74
|
+
`cancel_schedule` answers the single-email body the contract has always
|
|
75
|
+
published for it. The SDK had treated it as an empty envelope since 1.0, so
|
|
76
|
+
this is the type catching up to the document AND the route catching up to the
|
|
77
|
+
type.
|
|
78
|
+
|
|
79
|
+
- **`sentAt`, `deliveredAt` and `bouncedAt` are now declared on the email body.**
|
|
80
|
+
They were reaching callers only because of the whole-row leak above and were in
|
|
81
|
+
no published schema, so the honest options were to declare them or drop them.
|
|
82
|
+
Declared: they are ordinary delivery facts and callers read them. Each is
|
|
83
|
+
nullable, and null means the transition has not happened.
|
|
84
|
+
|
|
85
|
+
- **Engagement left the delivery status.** `OPENED`, `CLICKED` and `COMPLAINED`
|
|
86
|
+
are no longer delivery states, so they no longer appear in `email["status"]`
|
|
87
|
+
and are no longer accepted by the `status` filter on `emails.list`. The
|
|
88
|
+
remaining values are `PENDING`, `SENDING`, `SENT`, `DELIVERED`, `RECEIVED`,
|
|
89
|
+
`BOUNCED`, `FAILED`, `REJECTED`, `RENDERING_FAILURE`, `DELIVERY_DELAY` and
|
|
90
|
+
`CANCELLED`.
|
|
91
|
+
|
|
92
|
+
Read engagement from `openedAt` / `clickedAt` / `complainedAt` and the `opens`
|
|
93
|
+
/ `clicks` counters instead. The two were one enum, which meant a message that
|
|
94
|
+
had been opened stopped reporting that it had been delivered — a status can
|
|
95
|
+
only hold one value, and delivery and engagement are not alternatives.
|
|
96
|
+
|
|
97
|
+
- **The double-opt-in confirmation route moved** from `/api/lists/confirm` to
|
|
98
|
+
`/api/lists/confirm-subscription`. `lists.subscribe` documents that URL
|
|
99
|
+
because Sendly does not send the confirmation email — your application does —
|
|
100
|
+
so a caller who builds it by hand must change the path. The `confirmToken` in
|
|
101
|
+
the response is unchanged.
|
|
102
|
+
|
|
103
|
+
- **`Domain.name` is now `Domain.domain`**, and the record no longer carries a
|
|
104
|
+
ready-made `dkim` list of `{type, name, value}` records. What SES actually
|
|
105
|
+
hands back is a list of tokens, so that is what is published: **`dkimTokens`**,
|
|
106
|
+
the strings to publish as CNAME records. The old shape implied Sendly knew the
|
|
107
|
+
full record set; it knew the tokens and was assembling the rest.
|
|
108
|
+
|
|
109
|
+
- **`DomainVerificationStatus` reports one status per DNS record type.** `dkim`
|
|
110
|
+
and `mxRecords` are gone; `dkimStatus`, `spfStatus` and `dmarcStatus` take
|
|
111
|
+
their place, and `domain`, `status` and `mailFromDomain` are now required.
|
|
112
|
+
`status` is SES's own raw DKIM state (`Success`, `Pending`) and the three
|
|
113
|
+
`*Status` fields are this platform's DNS check — both are published because
|
|
114
|
+
they can disagree, and a single collapsed verdict hid which record was actually
|
|
115
|
+
failing.
|
|
116
|
+
|
|
117
|
+
- **The legacy suppression list answers a bare body.** `GET /api/suppression`
|
|
118
|
+
returns `{"items", "nextCursor"}` with no `{"success", "data"}` envelope, where
|
|
119
|
+
it previously published `{"success", "data", "hasMore", "cursor"}`.
|
|
120
|
+
`suppression.list` hands the body back untouched, so read `page["items"]` and
|
|
121
|
+
`page["nextCursor"]`. `nextCursor` is `None` on the last page and is never
|
|
122
|
+
omitted.
|
|
123
|
+
|
|
124
|
+
- **`webhooks.create` nests the endpoint beside the secret.** `data` is now
|
|
125
|
+
`{"webhook", "secret"}` rather than the webhook's fields spread alongside
|
|
126
|
+
`secret`. `created["data"]["secret"]` is unchanged; the endpoint's id moved to
|
|
127
|
+
`created["data"]["webhook"]["id"]`. Spreading a resource and a one-time
|
|
128
|
+
credential into one object made it impossible to hand the record onward without
|
|
129
|
+
carrying the secret with it.
|
|
130
|
+
|
|
131
|
+
- **`Webhook.lastFour` is gone.** A webhook record now states that it never
|
|
132
|
+
carries a secret, and a four-character fragment of one is still a fragment of
|
|
133
|
+
one. Nothing identified an endpoint by it — `id` and `url` do that.
|
|
134
|
+
|
|
135
|
+
### Added
|
|
136
|
+
|
|
137
|
+
- **The `/api/v1` half of six resources that had only a legacy one.** Both
|
|
138
|
+
dialects stay reachable, so the versioned methods carry a `_v1` suffix:
|
|
139
|
+
- `contacts` — `list_v1`, `iter_list_v1`, `create_v1`, `get_v1`, `update_v1`,
|
|
140
|
+
`delete_v1`, and `topic_preferences`.
|
|
141
|
+
- `lists` — `list_v1`, `iter_list_v1`, `create_v1`, `get_v1`, `update_v1`,
|
|
142
|
+
`delete_v1`, and `start_validation_run`.
|
|
143
|
+
- `templates` — `list_v1`, `iter_list_v1`, `create_v1`, `get_v1`,
|
|
144
|
+
`update_v1`, `delete_v1`.
|
|
145
|
+
- `domains` — `list_v1`, `iter_list_v1`, `create_v1`, `get_v1`, `verify_v1`,
|
|
146
|
+
`delete_v1`, plus the legacy `assign_stream`.
|
|
147
|
+
- `webhooks` — `list_v1`, `iter_list_v1`, `create_v1`, `get_v1`, `update_v1`,
|
|
148
|
+
`delete_v1`, `rotate_secret_v1`.
|
|
149
|
+
- `suppression` — `list_v1`, `iter_list_v1`, `create_v1`, `get_v1`,
|
|
150
|
+
`delete_v1`.
|
|
151
|
+
|
|
152
|
+
The suffix is not decoration. The two halves answer the same question with
|
|
153
|
+
different envelopes (`{success, data}` versus the bare body), different field
|
|
154
|
+
cases (camelCase versus snake_case) and different error bodies (the legacy
|
|
155
|
+
envelope versus RFC 9457), so a call site that mixes them up reads a `data`
|
|
156
|
+
that is not there and raises a `KeyError` a long way from the mistake. Naming
|
|
157
|
+
them apart is what makes that impossible.
|
|
158
|
+
|
|
159
|
+
One difference inside suppression is worth knowing before you swap: the v1
|
|
160
|
+
path parameter is an **address**, and v1 answers `404 resource_not_found` for
|
|
161
|
+
an address that is not suppressed, where the legacy `suppression.get` answers
|
|
162
|
+
`200 {"suppressed": False}`. Both are definite; only one of them raises.
|
|
163
|
+
|
|
164
|
+
- **`sendly.topics`** — `list`, `iter_list`, `create`, `get`, `update`,
|
|
165
|
+
`set_subscription`. The consent vocabulary a project mails against: a contact
|
|
166
|
+
subscribes to a topic rather than to a campaign, so switching one off silences
|
|
167
|
+
a whole audience. Two things a caller needs:
|
|
168
|
+
- **Subscribing somebody through the API does not bypass confirmation.**
|
|
169
|
+
`set_subscription(id, {"subscribed": True})` parks the contact at `pending`
|
|
170
|
+
and returns a `confirmation_url` that **your** application delivers, from
|
|
171
|
+
your own verified domain; nothing is mailed on the topic until someone opens
|
|
172
|
+
it. There is no parameter to skip that, because a subscription an API caller
|
|
173
|
+
asserts is not evidence the mailbox holder agreed.
|
|
174
|
+
- **A topic is archived, never deleted.** There is no `delete` method because
|
|
175
|
+
there is no delete route: a topic is where people's answers are recorded, so
|
|
176
|
+
deleting it would delete the choices they made against it.
|
|
177
|
+
`update(id, {"archived": True})` retires it and keeps them.
|
|
178
|
+
|
|
179
|
+
- **`sendly.snippets`** — `create`, `list`, `get`, `update`, `delete`. Reusable
|
|
180
|
+
body fragments a template includes with `{{> name}}`, on the legacy dialect,
|
|
181
|
+
gated by the same `templates:*` scopes as the templates that include them — a
|
|
182
|
+
snippet is part of a template body rather than a resource with an audience of
|
|
183
|
+
its own. Deleting one does not break its templates: an absent snippet renders
|
|
184
|
+
as an empty string, like an absent variable.
|
|
185
|
+
|
|
186
|
+
- **`sendly.validation`** — `validate_emails`, `get_run`, `list_results`,
|
|
187
|
+
`iter_list_results`. **Billed per address checked**: every entry in
|
|
188
|
+
`validate_emails({"emails": [...]})` costs money, so looping it over a contact
|
|
189
|
+
list is looping over your invoice. Validate a whole list with
|
|
190
|
+
`lists.start_validation_run`, a background job, and poll it with `get_run`.
|
|
191
|
+
|
|
192
|
+
A verdict of `unknown` is deliberately a distinct value from `undeliverable`:
|
|
193
|
+
it means DNS did not answer in time, so the address was **not checked**. That
|
|
194
|
+
separation exists so a DNS timeout is never grounds for deleting a contact.
|
|
195
|
+
|
|
196
|
+
- **`sendly.deliverability`** — `diagnose`, `list_domain_stats`,
|
|
197
|
+
`iter_list_domain_stats`, `list_dmarc_reports`, `iter_list_dmarc_reports`.
|
|
198
|
+
`list_domain_stats` is per **recipient** domain (`gmail.com`, `outlook.com`) —
|
|
199
|
+
the domains you send **to** — which is the axis `diagnose` cannot report: its
|
|
200
|
+
project-wide rates hide one provider refusing nearly everything while the rest
|
|
201
|
+
of your mail is healthy. DMARC reports arrive only for a policy domain the
|
|
202
|
+
project has registered, and receivers send them on their own schedule, so **an
|
|
203
|
+
empty list is correct rather than broken**.
|
|
204
|
+
|
|
205
|
+
- **`campaigns.list_failures`, `campaigns.iter_list_failures` and
|
|
206
|
+
`campaigns.retry_failed`.** `stats` says how many sends failed; only these say
|
|
207
|
+
who, and `reason` comes from a fixed vocabulary rather than the underlying
|
|
208
|
+
error text so it is stable enough to branch on. `retry_failed` re-drives
|
|
209
|
+
**only** the recipients whose send failed — nobody who already received the
|
|
210
|
+
campaign is mailed again, because each ledger row is claimed before it is
|
|
211
|
+
touched and a row whose email exists already is re-queued rather than re-sent.
|
|
212
|
+
Uniquely among v1 lists, `list_failures` also carries `total`: `retry_failed`
|
|
213
|
+
acts on that number, and `has_more` alone cannot tell you whether 3 or 30,000
|
|
214
|
+
sends failed.
|
|
215
|
+
|
|
216
|
+
- **`workflows.get_graph`, `workflows.replace_graph`, `workflows.clone`,
|
|
217
|
+
`workflows.pause` and `workflows.resume`.**
|
|
218
|
+
- `replace_graph` is a `PUT` because a graph is replaced whole: nodes *plus*
|
|
219
|
+
the edges between them, so a partial edit to a step list has no meaning
|
|
220
|
+
without the transitions that reference it. An id you omit deletes that step
|
|
221
|
+
and its run history; it is refused with `409 conflict` while executions are
|
|
222
|
+
running.
|
|
223
|
+
- `clone` always creates the copy **disabled**, whatever the original was — a
|
|
224
|
+
clone exists to be reviewed, and one that started live would match the same
|
|
225
|
+
trigger events as its original from the moment it appeared.
|
|
226
|
+
- `pause` cancels every `RUNNING`/`WAITING` execution and reports how many in
|
|
227
|
+
`cancelled_executions`. `resume` re-opens the workflow to new runs and does
|
|
228
|
+
**not** restore the cancelled ones (`cancelled_executions` is always 0
|
|
229
|
+
there). That asymmetry is the point of having both:
|
|
230
|
+
`update(id, {"enabled": False})` stops new runs and leaves every in-flight
|
|
231
|
+
contact walking the graph, `pause` stops the sends already in flight, and
|
|
232
|
+
nothing puts them back.
|
|
233
|
+
|
|
234
|
+
- **`mailboxes.send_message` and `mailboxes.draft_message`.** The mailbox
|
|
235
|
+
resource is no longer read-only.
|
|
236
|
+
- `send_message` **really sends**, as that mailbox's own address, over its own
|
|
237
|
+
domain, and the recipient can reply. There is no `from` field on purpose: a
|
|
238
|
+
route that sends under a customer's identity must not take that identity as
|
|
239
|
+
an argument. `body` is plain text and HTML is refused, so text becomes
|
|
240
|
+
markup in exactly one place. Needs `mailboxes:send`.
|
|
241
|
+
- `draft_message` asks Sendly's assistant to **write** text and hands it back.
|
|
242
|
+
It stores nothing and sends nothing — the response reports `sent: False`,
|
|
243
|
+
and no argument changes that — so it needs only `mailboxes:read`. A client
|
|
244
|
+
that may draft is not thereby a client that may mail your customers.
|
|
245
|
+
|
|
246
|
+
- **Auto-pagination for every new cursor list.** The `iter_*` companions now
|
|
247
|
+
number seventeen: the six from 0.2.0 plus `campaigns.iter_list_failures`,
|
|
248
|
+
`contacts.iter_list_v1`, `deliverability.iter_list_dmarc_reports`,
|
|
249
|
+
`deliverability.iter_list_domain_stats`, `domains.iter_list_v1`,
|
|
250
|
+
`lists.iter_list_v1`, `suppression.iter_list_v1`, `templates.iter_list_v1`,
|
|
251
|
+
`topics.iter_list`, `validation.iter_list_results` and
|
|
252
|
+
`webhooks.iter_list_v1`.
|
|
253
|
+
|
|
254
|
+
- **`intake_configured` on the DMARC report list**, and it is the field that
|
|
255
|
+
makes an empty page readable. `deliverability.list_dmarc_reports` answering
|
|
256
|
+
`"data": []` used to mean either "no receiver has reported a failure" or "this
|
|
257
|
+
deployment has no report intake mailbox, so nothing can ever arrive", and the
|
|
258
|
+
two were indistinguishable. `"intake_configured": False` is the second one.
|
|
259
|
+
Read it before telling anyone the domains are clean.
|
|
260
|
+
|
|
261
|
+
- **`Suppression.scope`** — `PROJECT` or `GLOBAL`. Every record this API creates
|
|
262
|
+
or returns today is `PROJECT`; `GLOBAL` is a platform-wide block recorded
|
|
263
|
+
outside your project, which is why `suppression.get_v1` can answer `200` for an
|
|
264
|
+
address you never suppressed yourself.
|
|
265
|
+
|
|
266
|
+
- **`Template.currentVersion`** — a counter incremented by an update that changes
|
|
267
|
+
the rendered content, and left alone by one that only renames. A campaign
|
|
268
|
+
records the version it sent, so this is how a caller tells "the template changed
|
|
269
|
+
since" from "the template was retitled".
|
|
270
|
+
|
|
271
|
+
- **`Webhook.domains`** — the sending domains an endpoint is scoped to, empty
|
|
272
|
+
meaning every domain on the project. It was already enforced; it is now
|
|
273
|
+
readable, so a caller can see why an endpoint is quiet.
|
|
274
|
+
|
|
275
|
+
- **`Webhook.previousSecretExpiresAt`** on the record itself, not only on the
|
|
276
|
+
rotation response. While a rotation is in flight it says when the OLD secret
|
|
277
|
+
stops being accepted, and it is `None` outside one — so a verifier can tell
|
|
278
|
+
from a plain read whether it is inside a dual-signature window.
|
|
279
|
+
|
|
280
|
+
- **Every `{id}` path parameter declares `format: uuid`,** and the seven
|
|
281
|
+
operations that had no `404` published now publish one:
|
|
282
|
+
`GET /api/v1/contacts/{id}/topics`, `POST /api/v1/lists/{id}/validation-runs`,
|
|
283
|
+
`GET` and `PATCH /api/v1/topics/{id}`,
|
|
284
|
+
`POST /api/v1/topics/{id}/subscriptions`, `GET /api/v1/validation-runs/{id}`
|
|
285
|
+
and its `/results`. All seven answered `404 resource_not_found` already; the
|
|
286
|
+
contract now says so, which is what the error-handling examples are read from.
|
|
287
|
+
|
|
288
|
+
### Fixed
|
|
289
|
+
|
|
290
|
+
- **README: `contacts.upsert` and `contacts.update` were documented with a
|
|
291
|
+
`data` key.** The legacy contact body's custom-field map is `customFields`;
|
|
292
|
+
`data` was silently ignored, so the example looked like it worked and stored
|
|
293
|
+
nothing. (`lists.subscribe` really does take `data` — that one is unchanged.)
|
|
294
|
+
- **README: the `segments.create` example's `condition` was not a filter
|
|
295
|
+
condition.** It showed `{"field": ..., "op": ..., "value": ...}`; the API
|
|
296
|
+
takes `{"logic", "groups"}`, each group holding `filters` of
|
|
297
|
+
`{"field", "operator", "value"}` with `operator` from a fixed vocabulary
|
|
298
|
+
(`equals`, `contains`, …). Copying the old example produced a `422`.
|
|
299
|
+
- **README: the mailbox resource was described as read-only** in two places. It
|
|
300
|
+
is not, since `send_message` and `draft_message`; what stays out of reach is
|
|
301
|
+
the mailbox *lifecycle*, which is a different claim.
|
|
302
|
+
|
|
303
|
+
### Notes
|
|
304
|
+
|
|
305
|
+
- **Pagination is uniform again.** Every v1 list takes `after` and answers
|
|
306
|
+
`next_cursor`. `topics.list` and `validation.list_results` were the two
|
|
307
|
+
exceptions through 1.0 — they took `cursor` and answered `cursor` — and the
|
|
308
|
+
platform collapsed that to one dialect for this release, so both now route
|
|
309
|
+
through the shared cursor helper like every other collection. **This is
|
|
310
|
+
breaking for a caller driving those two by hand**: pass `after` instead of
|
|
311
|
+
`cursor`, and read `next_cursor` instead of `cursor`. Anyone using
|
|
312
|
+
`topics.iter_list` or `validation.iter_list_results` is unaffected. The helper
|
|
313
|
+
also picked up the "stop if a page repeats the cursor it was handed" guard
|
|
314
|
+
those two walkers had, so consolidating them dropped nothing.
|
|
315
|
+
- **`NOT_SDK_CALLABLE` is unchanged.** Creating and deleting a mailbox, creating
|
|
316
|
+
and revoking an app password, the four API-key operations, and creating a
|
|
317
|
+
project still resolve the acting user from a session and answer `401` to any
|
|
318
|
+
API key. The two new mailbox methods are the opposite case — they publish
|
|
319
|
+
`ApiKeyAuth` outright.
|
|
320
|
+
- **Nothing added here takes an `idempotency_key`.** The set of writes that
|
|
321
|
+
accept one is the same as in 1.0: `emails.send`, `emails.send_legacy`,
|
|
322
|
+
`emails.batch`, `contacts.create`, `contacts.upsert`, `contacts.bulk_create`,
|
|
323
|
+
`campaigns.create` and `campaigns.send`. `campaigns.retry_failed` is guarded
|
|
324
|
+
instead by a `409 conflict` on a retry already running, which is a better fit:
|
|
325
|
+
the thing to prevent is two concurrent walks, not a replayed request.
|
|
326
|
+
|
|
327
|
+
## [1.0.0] - 2026-09-02
|
|
328
|
+
|
|
329
|
+
The default send moves to the versioned API. Everything else in this release is
|
|
330
|
+
additive: the operations an API key can actually reach that the SDK did not yet
|
|
331
|
+
expose.
|
|
332
|
+
|
|
333
|
+
### Breaking
|
|
334
|
+
|
|
335
|
+
- **`emails.send()` now posts to `POST /api/v1/emails`** and returns the bare
|
|
336
|
+
`202` receipt, `{id, status, to, from}`, where `status` is a real delivery
|
|
337
|
+
state. Before 1.0 it posted to the legacy `POST /api/emails`, which answered
|
|
338
|
+
with row ids and **no** delivery status, and fanned an array `to` out to
|
|
339
|
+
several recipients. What changes for a caller:
|
|
340
|
+
- one recipient in `to`, with `cc` / `bcc` to copy others (an array `to` is
|
|
341
|
+
no longer accepted);
|
|
342
|
+
- the result is the receipt, not `{emails, timestamp}` — read
|
|
343
|
+
`receipt["id"]` and `receipt["status"]` instead of
|
|
344
|
+
`result["emails"][0]["email"]`;
|
|
345
|
+
- failures arrive as RFC 9457 problem documents, raised as the **same**
|
|
346
|
+
exception classes, so `except` blocks are unchanged; `err.error_code` is now
|
|
347
|
+
the lowercase v1 registry value (`validation_error`, not
|
|
348
|
+
`VALIDATION_ERROR`) and `err.request_id` / `err.field_errors` are populated.
|
|
349
|
+
|
|
350
|
+
The pre-1.0 behaviour is kept, unchanged, as **`emails.send_legacy()`** — the
|
|
351
|
+
escape hatch for a caller that depends on the fan-out or the envelope.
|
|
352
|
+
Renaming a call from `send` to `send_legacy` is a complete migration; adopting
|
|
353
|
+
the new default means reading the receipt instead of the envelope.
|
|
354
|
+
|
|
355
|
+
Why now: the legacy send cannot tell a caller whether a message went anywhere,
|
|
356
|
+
and the versioned one can. Nothing is published against 0.x, so the cost of
|
|
357
|
+
the move is lowest today and only rises.
|
|
358
|
+
|
|
359
|
+
### Added
|
|
360
|
+
|
|
361
|
+
- **`emails.send_legacy()`** — the pre-1.0 `send()`, byte for byte. See Breaking.
|
|
362
|
+
- **`emails.send_test()`** — sandbox test send. The sandbox address is the
|
|
363
|
+
*sender*; the mail lands in the project owner's own verified inbox. Naming a
|
|
364
|
+
`from` is refused rather than ignored. Takes no `idempotency_key`.
|
|
365
|
+
- **`mailboxes` resource, reads only** — `list()`, `get(id)` (which carries the
|
|
366
|
+
IMAP/SMTP `settings` a mail client needs) and `list_app_passwords(id)`
|
|
367
|
+
(metadata only; the secret is never returned). The mailbox *writes* are not
|
|
368
|
+
missing but unreachable — see Notes.
|
|
369
|
+
- **`projects.get()`** — the project the credential resolves to. Takes no id.
|
|
370
|
+
Carries `sandbox_address`, which no public route published before.
|
|
371
|
+
- **`domains.start_setup(id)`** — begins the guided DNS hand-off and returns the
|
|
372
|
+
route's own `{token, connectUrl, expiresAt}`. Finishing setup means a person
|
|
373
|
+
opening `connectUrl`, so the SDK hands back the link rather than modelling the
|
|
374
|
+
flow behind it.
|
|
375
|
+
|
|
376
|
+
### Notes
|
|
377
|
+
|
|
378
|
+
- **`send_v1` and `send_test_v1` never shipped.** They existed briefly on `main`
|
|
379
|
+
between 0.2.0 and this release as the additive step before the repoint, and
|
|
380
|
+
are folded into `send` and `send_test` here. If you installed from GitHub in
|
|
381
|
+
that window, rename the calls.
|
|
382
|
+
- **Some operations are permanently not SDK-callable.** Creating and deleting a
|
|
383
|
+
mailbox, creating and revoking an app password, the API-key operations, and
|
|
384
|
+
creating a project all resolve the acting user from a session and answer `401`
|
|
385
|
+
to any API key. They are recorded in `tests/test_contract.py`'s
|
|
386
|
+
`NOT_SDK_CALLABLE`, which the suite asserts equals the set the contract itself
|
|
387
|
+
declares — in both directions. Use the dashboard or an OAuth connection.
|
|
388
|
+
- **A project is capped at 10 mailboxes**, counting only ``PROVISIONING``,
|
|
389
|
+
``ACTIVE`` and ``SUSPENDED``. ``FAILED`` rows are excluded from the cap but
|
|
390
|
+
are still returned by ``mailboxes.list()``, so a project that has had failed
|
|
391
|
+
provisions can list more than 10 — the ``list()`` docstring said "at most 10"
|
|
392
|
+
without that distinction and now states it.
|
|
393
|
+
|
|
394
|
+
## [0.2.0]
|
|
395
|
+
|
|
396
|
+
Adds Sendly's `/api/v1` surface. Purely additive — every existing method keeps
|
|
397
|
+
its name, signature, and behaviour.
|
|
398
|
+
|
|
399
|
+
### Added
|
|
400
|
+
|
|
401
|
+
- **New resources for the `/api/v1` surface**, wired onto the same client:
|
|
402
|
+
`sendly.campaigns`, `sendly.segments`, `sendly.workflows`, `sendly.analytics`
|
|
403
|
+
and `sendly.usage`, covering all 33 v1 operations. Unlike the legacy `/api/*`
|
|
404
|
+
resources, these return **bare resource bodies** — there is no
|
|
405
|
+
`{success, data}` envelope to unwrap.
|
|
406
|
+
- **v1 methods on the existing `events` resource**: `events.record` (the v1
|
|
407
|
+
counterpart of `events.track`, which is unchanged), `events.list`,
|
|
408
|
+
`events.list_names` and `events.stats`. `record` takes no `idempotency_key`:
|
|
409
|
+
events are append-only and the API deliberately does not ledger them.
|
|
410
|
+
- **Auto-pagination.** Each of the six cursor-paginated v1 listings gains an
|
|
411
|
+
`iter_*` companion yielding individual items and following the cursor for you:
|
|
412
|
+
`campaigns.iter_list`, `segments.iter_list`, `segments.iter_list_contacts`,
|
|
413
|
+
`workflows.iter_list`, `workflows.iter_list_executions`, `events.iter_list`.
|
|
414
|
+
The v1 list envelope is `{data, has_more, next_cursor}` with `limit` (1-100,
|
|
415
|
+
default 20) and `after` — no total, deliberately. Changing filters
|
|
416
|
+
mid-pagination invalidates the cursor and returns `422 validation_error`, so
|
|
417
|
+
the iterators hold the query fixed and only advance `after`.
|
|
418
|
+
- **RFC 9457 error support.** `application/problem+json` responses from `/api/v1`
|
|
419
|
+
map to the **same** exception classes as the legacy envelope, keyed off the
|
|
420
|
+
same statuses — existing `except` blocks are unaffected. The problem's `code`
|
|
421
|
+
becomes `err.error_code` (e.g. `scope_missing`, `quota_exhausted`,
|
|
422
|
+
`idempotency_key_reused`) and its `detail` (falling back to `title`) becomes
|
|
423
|
+
`err.message`. Two fields are new on `SendlyError`:
|
|
424
|
+
- `err.request_id` — correlation id from the problem document, `None` on the
|
|
425
|
+
legacy surface;
|
|
426
|
+
- `err.field_errors` — per-field `{pointer, code, message}` entries from a v1
|
|
427
|
+
`validation_error`, `None` when absent. The legacy per-field breakdown stays
|
|
428
|
+
at `err.body["error"]["details"]["errors"]`.
|
|
429
|
+
- **`sendly.lists`** — `lists.subscribe(id, body)` and
|
|
430
|
+
`lists.unsubscribe(id, body)` wrap the newly published
|
|
431
|
+
`POST /api/lists/{id}/subscribe` and `.../unsubscribe` operations. Both accept
|
|
432
|
+
sending-only (`pk_*`) keys so they can back a public form. On a double opt-in
|
|
433
|
+
list, subscribe returns `PENDING` with a `confirmToken` and Sendly does **not**
|
|
434
|
+
send the confirmation email — the caller delivers it. Re-subscribing an address
|
|
435
|
+
that opted out needs `allowResubscribe: true` or fails with
|
|
436
|
+
`409 RESUBSCRIBE_CONFIRMATION_REQUIRED`.
|
|
437
|
+
|
|
438
|
+
### Changed
|
|
439
|
+
|
|
440
|
+
- Re-synced the vendored OpenAPI spec (`tests/fixtures/openapi.json`) to the
|
|
441
|
+
committed monorepo contract. The client stays thin (opaque `Mapping` bodies),
|
|
442
|
+
so these are contract/behaviour clarifications rather than method-signature
|
|
443
|
+
changes:
|
|
444
|
+
- **Deletes now return HTTP `200` with the deleted resource's id** (was `204`
|
|
445
|
+
No Content) for `contacts.delete` and `templates.delete`. The SDK still
|
|
446
|
+
discards the body and returns `None` — no consumer change.
|
|
447
|
+
- **Invalid input now raises `SendlyValidationError` from HTTP `422`**
|
|
448
|
+
(`error_code == "VALIDATION_ERROR"`) with a per-field breakdown at
|
|
449
|
+
`err.body["error"]["details"]["errors"]`. Previously invalid input came back
|
|
450
|
+
as `400`. Both `400` and `422` map to `SendlyValidationError`, so
|
|
451
|
+
`except SendlyValidationError` continues to catch validation failures.
|
|
452
|
+
- **Contacts bulk ops (`bulk_create`, `bulk_delete`) against an unresolved
|
|
453
|
+
project now return `422 VALIDATION_ERROR`** (was a `NO_PROJECT` error).
|
|
454
|
+
- **`templates.list` is cursor-paginated** (`limit` / `cursor`) — the former
|
|
455
|
+
`page` / `pageSize` query params are gone. `contacts.list` was already
|
|
456
|
+
cursor-based and is unchanged.
|
|
457
|
+
- Error envelopes on migrated routes now include `success: false` alongside
|
|
458
|
+
`error.{message,code}`; error parsing reads `message`/`code` and is
|
|
459
|
+
unaffected by the additive fields.
|