sendly-python 1.0.0__tar.gz → 1.2.0__tar.gz

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