sendly-python 0.2.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.
Files changed (84) hide show
  1. {sendly_python-0.2.0 → sendly_python-1.1.0}/.github/workflows/ci.yml +18 -6
  2. sendly_python-1.1.0/CHANGELOG.md +459 -0
  3. sendly_python-1.1.0/PKG-INFO +1323 -0
  4. sendly_python-1.1.0/README.md +1291 -0
  5. {sendly_python-0.2.0 → sendly_python-1.1.0}/pyproject.toml +1 -1
  6. sendly_python-1.1.0/scripts/sync_spec.py +301 -0
  7. {sendly_python-0.2.0 → sendly_python-1.1.0}/src/sendly/__init__.py +15 -1
  8. {sendly_python-0.2.0 → sendly_python-1.1.0}/src/sendly/client.py +14 -1
  9. {sendly_python-0.2.0 → sendly_python-1.1.0}/src/sendly/resources/_pagination.py +6 -3
  10. {sendly_python-0.2.0 → sendly_python-1.1.0}/src/sendly/resources/campaigns.py +43 -0
  11. sendly_python-1.1.0/src/sendly/resources/contacts.py +191 -0
  12. sendly_python-1.1.0/src/sendly/resources/deliverability.py +109 -0
  13. sendly_python-1.1.0/src/sendly/resources/domains.py +210 -0
  14. sendly_python-1.1.0/src/sendly/resources/emails.py +132 -0
  15. {sendly_python-0.2.0 → sendly_python-1.1.0}/src/sendly/resources/events.py +3 -1
  16. sendly_python-1.1.0/src/sendly/resources/lists.py +152 -0
  17. sendly_python-1.1.0/src/sendly/resources/mailboxes.py +133 -0
  18. sendly_python-1.1.0/src/sendly/resources/projects.py +33 -0
  19. sendly_python-1.1.0/src/sendly/resources/snippets.py +72 -0
  20. sendly_python-1.1.0/src/sendly/resources/suppression.py +131 -0
  21. sendly_python-1.1.0/src/sendly/resources/templates.py +147 -0
  22. sendly_python-1.1.0/src/sendly/resources/topics.py +109 -0
  23. sendly_python-1.1.0/src/sendly/resources/validation.py +99 -0
  24. sendly_python-1.1.0/src/sendly/resources/webhooks.py +188 -0
  25. {sendly_python-0.2.0 → sendly_python-1.1.0}/src/sendly/resources/workflows.py +94 -0
  26. sendly_python-1.1.0/src/sendly/types.py +242 -0
  27. sendly_python-1.1.0/tests/fixtures/openapi.json +25307 -0
  28. {sendly_python-0.2.0 → sendly_python-1.1.0}/tests/support.py +8 -4
  29. {sendly_python-0.2.0 → sendly_python-1.1.0}/tests/test_campaigns.py +88 -0
  30. sendly_python-1.1.0/tests/test_contacts.py +220 -0
  31. {sendly_python-0.2.0 → sendly_python-1.1.0}/tests/test_contract.py +118 -19
  32. sendly_python-1.1.0/tests/test_deliverability.py +153 -0
  33. sendly_python-1.1.0/tests/test_domains.py +255 -0
  34. sendly_python-1.1.0/tests/test_emails.py +225 -0
  35. sendly_python-1.1.0/tests/test_lists.py +255 -0
  36. sendly_python-1.1.0/tests/test_mailboxes.py +197 -0
  37. sendly_python-1.1.0/tests/test_projects.py +58 -0
  38. sendly_python-1.1.0/tests/test_snippets.py +103 -0
  39. sendly_python-1.1.0/tests/test_suppression.py +158 -0
  40. sendly_python-1.1.0/tests/test_templates.py +184 -0
  41. sendly_python-1.1.0/tests/test_topics.py +152 -0
  42. sendly_python-1.1.0/tests/test_validation.py +162 -0
  43. sendly_python-1.1.0/tests/test_webhooks.py +186 -0
  44. {sendly_python-0.2.0 → sendly_python-1.1.0}/tests/test_workflows.py +109 -1
  45. sendly_python-0.2.0/CHANGELOG.md +0 -73
  46. sendly_python-0.2.0/PKG-INFO +0 -483
  47. sendly_python-0.2.0/README.md +0 -451
  48. sendly_python-0.2.0/scripts/sync_spec.py +0 -140
  49. sendly_python-0.2.0/src/sendly/resources/contacts.py +0 -93
  50. sendly_python-0.2.0/src/sendly/resources/domains.py +0 -67
  51. sendly_python-0.2.0/src/sendly/resources/emails.py +0 -73
  52. sendly_python-0.2.0/src/sendly/resources/lists.py +0 -59
  53. sendly_python-0.2.0/src/sendly/resources/suppression.py +0 -52
  54. sendly_python-0.2.0/src/sendly/resources/templates.py +0 -61
  55. sendly_python-0.2.0/src/sendly/resources/webhooks.py +0 -76
  56. sendly_python-0.2.0/src/sendly/types.py +0 -125
  57. sendly_python-0.2.0/tests/fixtures/openapi.json +0 -11361
  58. sendly_python-0.2.0/tests/test_contacts.py +0 -97
  59. sendly_python-0.2.0/tests/test_domains.py +0 -53
  60. sendly_python-0.2.0/tests/test_emails.py +0 -94
  61. sendly_python-0.2.0/tests/test_lists.py +0 -123
  62. sendly_python-0.2.0/tests/test_suppression.py +0 -49
  63. sendly_python-0.2.0/tests/test_templates.py +0 -56
  64. sendly_python-0.2.0/tests/test_webhooks.py +0 -66
  65. {sendly_python-0.2.0 → sendly_python-1.1.0}/.github/workflows/release.yml +0 -0
  66. {sendly_python-0.2.0 → sendly_python-1.1.0}/.gitignore +0 -0
  67. {sendly_python-0.2.0 → sendly_python-1.1.0}/LICENSE +0 -0
  68. {sendly_python-0.2.0 → sendly_python-1.1.0}/src/sendly/errors.py +0 -0
  69. {sendly_python-0.2.0 → sendly_python-1.1.0}/src/sendly/py.typed +0 -0
  70. {sendly_python-0.2.0 → sendly_python-1.1.0}/src/sendly/resources/__init__.py +0 -0
  71. {sendly_python-0.2.0 → sendly_python-1.1.0}/src/sendly/resources/_helpers.py +0 -0
  72. {sendly_python-0.2.0 → sendly_python-1.1.0}/src/sendly/resources/analytics.py +0 -0
  73. {sendly_python-0.2.0 → sendly_python-1.1.0}/src/sendly/resources/segments.py +0 -0
  74. {sendly_python-0.2.0 → sendly_python-1.1.0}/src/sendly/resources/usage.py +0 -0
  75. {sendly_python-0.2.0 → sendly_python-1.1.0}/src/sendly/resources/verify.py +0 -0
  76. {sendly_python-0.2.0 → sendly_python-1.1.0}/src/sendly/webhook_utils.py +0 -0
  77. {sendly_python-0.2.0 → sendly_python-1.1.0}/tests/test_analytics.py +0 -0
  78. {sendly_python-0.2.0 → sendly_python-1.1.0}/tests/test_client.py +0 -0
  79. {sendly_python-0.2.0 → sendly_python-1.1.0}/tests/test_errors_problem.py +0 -0
  80. {sendly_python-0.2.0 → sendly_python-1.1.0}/tests/test_events.py +0 -0
  81. {sendly_python-0.2.0 → sendly_python-1.1.0}/tests/test_segments.py +0 -0
  82. {sendly_python-0.2.0 → sendly_python-1.1.0}/tests/test_usage.py +0 -0
  83. {sendly_python-0.2.0 → sendly_python-1.1.0}/tests/test_verify.py +0 -0
  84. {sendly_python-0.2.0 → sendly_python-1.1.0}/tests/test_webhook_verify.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 live OpenAPI drift even without a push.
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 live API so drift shows
42
- # up as a warning annotation without failing the build. Runs once (on the
43
- # newest interpreter) to avoid duplicate live fetches and annotations.
44
- - name: Live OpenAPI drift check (non-blocking)
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 live spec at https://api.sendly.now/api/openapi.json. Run 'python scripts/sync_spec.py' and commit the refreshed copy."
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."
@@ -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.