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

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