sendly-python 1.1.0__tar.gz → 1.3.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 (66) hide show
  1. {sendly_python-1.1.0 → sendly_python-1.3.0}/.github/workflows/ci.yml +1 -0
  2. {sendly_python-1.1.0 → sendly_python-1.3.0}/.github/workflows/release.yml +1 -0
  3. {sendly_python-1.1.0 → sendly_python-1.3.0}/CHANGELOG.md +50 -0
  4. sendly_python-1.1.0/README.md → sendly_python-1.3.0/PKG-INFO +118 -2
  5. sendly_python-1.1.0/PKG-INFO → sendly_python-1.3.0/README.md +86 -34
  6. {sendly_python-1.1.0 → sendly_python-1.3.0}/pyproject.toml +3 -3
  7. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/__init__.py +2 -0
  8. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/client.py +64 -25
  9. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/domains.py +58 -0
  10. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/mailboxes.py +116 -3
  11. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/types.py +30 -1
  12. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/fixtures/openapi.json +32594 -11242
  13. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_contract.py +179 -8
  14. sendly_python-1.3.0/tests/test_domain_logo.py +81 -0
  15. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_emails.py +14 -0
  16. sendly_python-1.3.0/tests/test_mailbox_conversations.py +132 -0
  17. {sendly_python-1.1.0 → sendly_python-1.3.0}/.gitignore +0 -0
  18. {sendly_python-1.1.0 → sendly_python-1.3.0}/LICENSE +0 -0
  19. {sendly_python-1.1.0 → sendly_python-1.3.0}/scripts/sync_spec.py +0 -0
  20. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/errors.py +0 -0
  21. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/py.typed +0 -0
  22. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/__init__.py +0 -0
  23. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/_helpers.py +0 -0
  24. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/_pagination.py +0 -0
  25. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/analytics.py +0 -0
  26. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/campaigns.py +0 -0
  27. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/contacts.py +0 -0
  28. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/deliverability.py +0 -0
  29. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/emails.py +0 -0
  30. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/events.py +0 -0
  31. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/lists.py +0 -0
  32. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/projects.py +0 -0
  33. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/segments.py +0 -0
  34. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/snippets.py +0 -0
  35. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/suppression.py +0 -0
  36. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/templates.py +0 -0
  37. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/topics.py +0 -0
  38. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/usage.py +0 -0
  39. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/validation.py +0 -0
  40. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/verify.py +0 -0
  41. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/webhooks.py +0 -0
  42. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/workflows.py +0 -0
  43. {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/webhook_utils.py +0 -0
  44. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/support.py +0 -0
  45. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_analytics.py +0 -0
  46. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_campaigns.py +0 -0
  47. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_client.py +0 -0
  48. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_contacts.py +0 -0
  49. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_deliverability.py +0 -0
  50. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_domains.py +0 -0
  51. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_errors_problem.py +0 -0
  52. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_events.py +0 -0
  53. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_lists.py +0 -0
  54. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_mailboxes.py +0 -0
  55. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_projects.py +0 -0
  56. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_segments.py +0 -0
  57. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_snippets.py +0 -0
  58. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_suppression.py +0 -0
  59. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_templates.py +0 -0
  60. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_topics.py +0 -0
  61. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_usage.py +0 -0
  62. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_validation.py +0 -0
  63. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_verify.py +0 -0
  64. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_webhook_verify.py +0 -0
  65. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_webhooks.py +0 -0
  66. {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_workflows.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)
@@ -3,6 +3,56 @@
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]
7
+
8
+ ## [1.3.0] - 2026-11-07
9
+
10
+ ### Added
11
+
12
+ - **Why a message bounced or was complained about.** An email (`emails.get`, `emails.list`)
13
+ gains the keys `bounceType`, `bounceSubType`, `bounceStatusCode`, `bounceDiagnosticCode` and
14
+ `complaintFeedbackType`; each entry of its delivery history gains the four `bounce*` keys. The
15
+ `email.bounced` webhook body gains `bounceSubType`, `bounceStatusCode` and
16
+ `bounceDiagnosticCode`, and `email.complained` gains `complaintFeedbackType`. All are `str | None`
17
+ and additive: responses are plain dicts, so existing code is unaffected.
18
+ - **Three other optional fields arrive with the re-sync:** `Email.hold`, `Problem.next` and
19
+ `CreateApiKeyBody.tiers`. All are optional and additive.
20
+
21
+ ### Changed
22
+
23
+ - **The vendored contract is re-synced** from the released one (`docs.sendly.now/openapi.json`),
24
+ which had grown from 106 to 198 paths since the last sync. The additions are account, dashboard
25
+ and member-management routes plus key-callable operations this SDK has no method for yet; the
26
+ contract suite lists them in `NOT_SDK_CALLABLE` / `NOT_YET_IMPLEMENTED` rather than shipping
27
+ them unreviewed. No existing method changed.
28
+
29
+ ## [1.2.0] - 2026-11-03
30
+
31
+ ### Added
32
+
33
+ - **Mailbox mail.** `mailboxes.list_conversations` / `iter_list_conversations`,
34
+ `get_conversation`, `reply_to_conversation` and `download_attachment`, under
35
+ the opt-in `mailbox-conversations:read` / `:reply` scopes, which no existing
36
+ key carries.
37
+ - **The sender logo (BIMI).** `domains.get_logo_v1`, `upload_logo_v1`,
38
+ `delete_logo_v1` and `get_logo_readiness_v1`.
39
+ - **`client.request_bytes`** for an operation that answers a file. It returns a
40
+ `SendlyFile(data: bytes, content_type, content_disposition)` and raises the
41
+ same typed errors. `request` reads the body as text, which mangles a binary file.
42
+ - **`emails.send` takes `listUnsubscribe`**, the https URL of your own one-click
43
+ unsubscribe endpoint. A transactional send carries no unsubscribe of Sendly's,
44
+ because Sendly cannot know what the reader would be opting out of; with this
45
+ field Sendly writes `List-Unsubscribe: <url>` and
46
+ `List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058), so the mail
47
+ client shows its own unsubscribe button. The two headers stay reserved in
48
+ `headers`. The server answers `422` for a value that is not https, is over
49
+ 2048 characters, or contains whitespace, a comma or angle brackets. Campaign
50
+ and workflow sends ignore it and keep Sendly's own link.
51
+ - **The vendored spec is re-synced** from the platform contract. The new mailbox
52
+ conversation and domain-logo operations are listed as not yet implemented, and
53
+ the provider compat dialects under `/api/compat/` are excluded by path: those
54
+ are for the other providers' own SDKs.
55
+
6
56
  ## [1.1.0] - 2026-09-05
7
57
 
8
58
  Four new resources, the `/api/v1` half of six that only had a legacy one, and a
@@ -1,5 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: sendly-python
3
+ Version: 1.3.0
4
+ Summary: Official Python SDK for Sendly (sendly.now), the email API and platform for developers by Devino Solutions: transactional email, contacts, templates and webhooks.
5
+ Project-URL: Homepage, https://sendly.now
6
+ Project-URL: Documentation, https://docs.sendly.now
7
+ Project-URL: Repository, https://github.com/DevinoSolutions/sendly-python
8
+ Author-email: Devino Solutions <dev@devino.ca>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: api,email,email-api,sdk,sendly,sendly.now,transactional-email,webhooks
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Communications :: Email
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: httpx==0.28.1
27
+ Provides-Extra: dev
28
+ Requires-Dist: mypy==1.15.0; extra == 'dev'
29
+ Requires-Dist: pytest==8.3.4; extra == 'dev'
30
+ Requires-Dist: ruff==0.9.6; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
1
33
  # Sendly Python SDK
2
34
 
35
+ **Sendly is an email platform for developers: one REST API for transactional email, marketing
36
+ campaigns and visual automation workflows, built on AWS SES, with contacts, segments, templates,
37
+ webhooks and an event stream behind the same account.** It offers a free tier and metered
38
+ pay-as-you-go sending. Sendly is made by Devino Solutions.
39
+
40
+ What Sendly is not: not a multi-channel messaging suite (email is the only channel; no SMS, push or
41
+ in-app messages), not a marketing suite (no landing pages, forms, ads or CRM), and not related to
42
+ other products or companies that use the word "Sendly". The PyPI package `sendly` is not ours;
43
+ this SDK is published as `sendly-python`.
44
+
45
+ Canonical site: https://sendly.now · Docs: https://docs.sendly.now · API: https://api.sendly.now
46
+
3
47
  Official Python SDK for the [Sendly](https://sendly.now) REST API —
4
48
  transactional email, contacts, lists, topics, events, domains, templates,
5
49
  snippets, email verification, address validation, deliverability reporting,
@@ -233,6 +277,13 @@ receipt = sendly.emails.send({"from": "a@you.com", "to": "b@them.com", "subject"
233
277
  sendly.emails.send_legacy({"from": "a@you.com", "to": ["b@them.com", "c@them.com"],
234
278
  "subject": "Hi", "body": "<p>Hi</p>"})
235
279
 
280
+ # Your own one-click unsubscribe (https only). Sendly writes the RFC 8058
281
+ # List-Unsubscribe headers, so the mail client shows its unsubscribe button; the
282
+ # endpoint gets a cookie-less POST and must unsubscribe the reader on it alone.
283
+ sendly.emails.send({"from": "notify@you.com", "to": "b@them.com", "subject": "New reply",
284
+ "body": "<p>Someone replied.</p>",
285
+ "listUnsubscribe": "https://you.com/unsubscribe?token=abc123"})
286
+
236
287
  # Batch send (up to 100)
237
288
  sendly.emails.batch({"emails": [{"from": "a@you.com", "to": "b@them.com", "subject": "Hi", "body": "<p>Hi</p>"}]})
238
289
 
@@ -260,6 +311,26 @@ for event in email["events"]:
260
311
  somebody opened, read `openedAt` / `opens` on the rows — engagement is not a
261
312
  status.
262
313
 
314
+ A bounced or complained message says why. Each key below is `str | None` and is
315
+ `None` until the event happens (or when the receiving server did not say):
316
+
317
+ ```python
318
+ email = sendly.emails.get("em_123")["data"]
319
+ if email["status"] == "BOUNCED":
320
+ # `bounceType` is "Permanent" or "Undetermined" here (a "Transient" bounce is
321
+ # retried and never sets BOUNCED); the rest are the receiving server's own words.
322
+ # Show them to a person, do not parse them.
323
+ print(email["bounceType"], email["bounceSubType"],
324
+ email["bounceStatusCode"], email["bounceDiagnosticCode"])
325
+ if email["complainedAt"] is not None:
326
+ print(email["complaintFeedbackType"]) # e.g. "abuse"
327
+ ```
328
+
329
+ Each entry of `email["events"]` carries the same four `bounce*` keys for the
330
+ transition that recorded them. The `email.bounced` webhook body adds
331
+ `bounceSubType`, `bounceStatusCode` and `bounceDiagnosticCode`, and
332
+ `email.complained` adds `complaintFeedbackType`.
333
+
263
334
  ### Contacts
264
335
 
265
336
  ```python
@@ -432,6 +503,28 @@ neither is a spelling of the other. `verify_v1` verifies nothing itself:
432
503
  verification happens in the domain's DNS when its owner publishes the DKIM
433
504
  records SES minted at creation, and this call asks SES what it currently sees.
434
505
 
506
+ #### The sender logo (BIMI)
507
+
508
+ Each identity serves a logo that BIMI-capable inboxes show beside its mail, once
509
+ its DNS is ready:
510
+
511
+ ```python
512
+ logo = sendly.domains.get_logo_v1("d_123")
513
+ print(logo["bimi_record"]) # publish this TXT record once; the URL never changes
514
+
515
+ sendly.domains.upload_logo_v1("d_123", {"svg": svg_source}) # square, shapes not <text>
516
+ for item in sendly.domains.get_logo_readiness_v1("d_123")["items"]:
517
+ print(item["key"], item["status"], item["reason"])
518
+ ```
519
+
520
+ The upload is rebuilt into SVG Tiny PS, the profile BIMI requires. A file that
521
+ cannot be rebuilt (text not converted to outlines, a raster image, a canvas that
522
+ is not square, over 32 KB once cleaned) is a 422 `validation_error` naming what to
523
+ change, and the previous logo stays. `delete_logo_v1` goes back to the default
524
+ (the domain's initial). Providers still decide whether to show a logo, and Gmail
525
+ and Apple Mail need a VMC or CMC certificate, which Sendly neither issues nor
526
+ validates.
527
+
435
528
  ### Mailboxes
436
529
 
437
530
  Receiving mailboxes on the project's verified domains. The reads are reads; the
@@ -452,8 +545,8 @@ for pw in sendly.mailboxes.list_app_passwords("mb_123"):
452
545
  print(pw["name"], pw["lastFour"], pw["lastUsedAt"])
453
546
  ```
454
547
 
455
- This lists the mailboxes themselves, never their contents — received messages
456
- are not part of the public API. The mailbox **password** is never returned by
548
+ `list` and `get` describe the mailboxes themselves, never their contents (the
549
+ mail is below, under a scope of its own). The mailbox **password** is never returned by
457
550
  any of these reads; mailbox credentials are app passwords, created from the
458
551
  dashboard and shown once. `list_app_passwords` returns only the passwords that
459
552
  are still active — a revoked one drops out, so this is not an audit history.
@@ -509,6 +602,29 @@ treated strictly as data describing what to write, never as instructions to the
509
602
  model. Capped at 120 requests an hour per project; 502 means the model was
510
603
  unreachable.
511
604
 
605
+ #### The mail itself: read, reply, download
606
+
607
+ These read correspondence from people outside your project, so they need
608
+ `mailbox-conversations:read`, and answering needs `mailbox-conversations:reply`.
609
+ Both scopes are **opt-in only**: a key holds one only if it was ticked by name
610
+ when the key was created, and neither the legacy full-access key nor the "Full
611
+ access" preset carries it. An existing key gets 403 until you mint one that does.
612
+
613
+ ```python
614
+ for thread in sendly.mailboxes.iter_list_conversations("mb_123", {"status": "open"}):
615
+ messages = sendly.mailboxes.get_conversation("mb_123", thread["id"]) # oldest first
616
+
617
+ sendly.mailboxes.reply_to_conversation("mb_123", thread_id, {"body": "Sorted, thanks."})
618
+
619
+ file = sendly.mailboxes.download_attachment("mb_123", attachment_id)
620
+ # file.data is bytes; file.content_type is the SENDER's claim.
621
+ ```
622
+
623
+ A reply names neither the recipient nor the sender: it answers whoever last wrote
624
+ in, from the mailbox's own address, in the same thread. Message bodies and
625
+ attachments were written by the sender, so treat `htmlBody`, the bytes and their
626
+ content type as untrusted. Reading marks nothing as read.
627
+
512
628
  ### Templates and snippets
513
629
 
514
630
  ```python
@@ -1,37 +1,17 @@
1
- Metadata-Version: 2.4
2
- Name: sendly-python
3
- Version: 1.1.0
4
- Summary: Official Sendly Python SDK
5
- Project-URL: Homepage, https://sendly.now
6
- Project-URL: Documentation, https://docs.sendly.now
7
- Project-URL: Repository, https://github.com/DevinoSolutions/sendly-python
8
- Author-email: Devino Solutions <dev@devino.ca>
9
- License: MIT
10
- License-File: LICENSE
11
- Keywords: api,email,sdk,sendly,transactional-email,webhooks
12
- Classifier: Development Status :: 4 - Beta
13
- Classifier: Intended Audience :: Developers
14
- Classifier: License :: OSI Approved :: MIT License
15
- Classifier: Operating System :: OS Independent
16
- Classifier: Programming Language :: Python :: 3
17
- Classifier: Programming Language :: Python :: 3 :: Only
18
- Classifier: Programming Language :: Python :: 3.10
19
- Classifier: Programming Language :: Python :: 3.11
20
- Classifier: Programming Language :: Python :: 3.12
21
- Classifier: Programming Language :: Python :: 3.13
22
- Classifier: Topic :: Communications :: Email
23
- Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
- Classifier: Typing :: Typed
25
- Requires-Python: >=3.10
26
- Requires-Dist: httpx==0.28.1
27
- Provides-Extra: dev
28
- Requires-Dist: mypy==1.15.0; extra == 'dev'
29
- Requires-Dist: pytest==8.3.4; extra == 'dev'
30
- Requires-Dist: ruff==0.9.6; extra == 'dev'
31
- Description-Content-Type: text/markdown
32
-
33
1
  # Sendly Python SDK
34
2
 
3
+ **Sendly is an email platform for developers: one REST API for transactional email, marketing
4
+ campaigns and visual automation workflows, built on AWS SES, with contacts, segments, templates,
5
+ webhooks and an event stream behind the same account.** It offers a free tier and metered
6
+ pay-as-you-go sending. Sendly is made by Devino Solutions.
7
+
8
+ What Sendly is not: not a multi-channel messaging suite (email is the only channel; no SMS, push or
9
+ in-app messages), not a marketing suite (no landing pages, forms, ads or CRM), and not related to
10
+ other products or companies that use the word "Sendly". The PyPI package `sendly` is not ours;
11
+ this SDK is published as `sendly-python`.
12
+
13
+ Canonical site: https://sendly.now · Docs: https://docs.sendly.now · API: https://api.sendly.now
14
+
35
15
  Official Python SDK for the [Sendly](https://sendly.now) REST API —
36
16
  transactional email, contacts, lists, topics, events, domains, templates,
37
17
  snippets, email verification, address validation, deliverability reporting,
@@ -265,6 +245,13 @@ receipt = sendly.emails.send({"from": "a@you.com", "to": "b@them.com", "subject"
265
245
  sendly.emails.send_legacy({"from": "a@you.com", "to": ["b@them.com", "c@them.com"],
266
246
  "subject": "Hi", "body": "<p>Hi</p>"})
267
247
 
248
+ # Your own one-click unsubscribe (https only). Sendly writes the RFC 8058
249
+ # List-Unsubscribe headers, so the mail client shows its unsubscribe button; the
250
+ # endpoint gets a cookie-less POST and must unsubscribe the reader on it alone.
251
+ sendly.emails.send({"from": "notify@you.com", "to": "b@them.com", "subject": "New reply",
252
+ "body": "<p>Someone replied.</p>",
253
+ "listUnsubscribe": "https://you.com/unsubscribe?token=abc123"})
254
+
268
255
  # Batch send (up to 100)
269
256
  sendly.emails.batch({"emails": [{"from": "a@you.com", "to": "b@them.com", "subject": "Hi", "body": "<p>Hi</p>"}]})
270
257
 
@@ -292,6 +279,26 @@ for event in email["events"]:
292
279
  somebody opened, read `openedAt` / `opens` on the rows — engagement is not a
293
280
  status.
294
281
 
282
+ A bounced or complained message says why. Each key below is `str | None` and is
283
+ `None` until the event happens (or when the receiving server did not say):
284
+
285
+ ```python
286
+ email = sendly.emails.get("em_123")["data"]
287
+ if email["status"] == "BOUNCED":
288
+ # `bounceType` is "Permanent" or "Undetermined" here (a "Transient" bounce is
289
+ # retried and never sets BOUNCED); the rest are the receiving server's own words.
290
+ # Show them to a person, do not parse them.
291
+ print(email["bounceType"], email["bounceSubType"],
292
+ email["bounceStatusCode"], email["bounceDiagnosticCode"])
293
+ if email["complainedAt"] is not None:
294
+ print(email["complaintFeedbackType"]) # e.g. "abuse"
295
+ ```
296
+
297
+ Each entry of `email["events"]` carries the same four `bounce*` keys for the
298
+ transition that recorded them. The `email.bounced` webhook body adds
299
+ `bounceSubType`, `bounceStatusCode` and `bounceDiagnosticCode`, and
300
+ `email.complained` adds `complaintFeedbackType`.
301
+
295
302
  ### Contacts
296
303
 
297
304
  ```python
@@ -464,6 +471,28 @@ neither is a spelling of the other. `verify_v1` verifies nothing itself:
464
471
  verification happens in the domain's DNS when its owner publishes the DKIM
465
472
  records SES minted at creation, and this call asks SES what it currently sees.
466
473
 
474
+ #### The sender logo (BIMI)
475
+
476
+ Each identity serves a logo that BIMI-capable inboxes show beside its mail, once
477
+ its DNS is ready:
478
+
479
+ ```python
480
+ logo = sendly.domains.get_logo_v1("d_123")
481
+ print(logo["bimi_record"]) # publish this TXT record once; the URL never changes
482
+
483
+ sendly.domains.upload_logo_v1("d_123", {"svg": svg_source}) # square, shapes not <text>
484
+ for item in sendly.domains.get_logo_readiness_v1("d_123")["items"]:
485
+ print(item["key"], item["status"], item["reason"])
486
+ ```
487
+
488
+ The upload is rebuilt into SVG Tiny PS, the profile BIMI requires. A file that
489
+ cannot be rebuilt (text not converted to outlines, a raster image, a canvas that
490
+ is not square, over 32 KB once cleaned) is a 422 `validation_error` naming what to
491
+ change, and the previous logo stays. `delete_logo_v1` goes back to the default
492
+ (the domain's initial). Providers still decide whether to show a logo, and Gmail
493
+ and Apple Mail need a VMC or CMC certificate, which Sendly neither issues nor
494
+ validates.
495
+
467
496
  ### Mailboxes
468
497
 
469
498
  Receiving mailboxes on the project's verified domains. The reads are reads; the
@@ -484,8 +513,8 @@ for pw in sendly.mailboxes.list_app_passwords("mb_123"):
484
513
  print(pw["name"], pw["lastFour"], pw["lastUsedAt"])
485
514
  ```
486
515
 
487
- This lists the mailboxes themselves, never their contents — received messages
488
- are not part of the public API. The mailbox **password** is never returned by
516
+ `list` and `get` describe the mailboxes themselves, never their contents (the
517
+ mail is below, under a scope of its own). The mailbox **password** is never returned by
489
518
  any of these reads; mailbox credentials are app passwords, created from the
490
519
  dashboard and shown once. `list_app_passwords` returns only the passwords that
491
520
  are still active — a revoked one drops out, so this is not an audit history.
@@ -541,6 +570,29 @@ treated strictly as data describing what to write, never as instructions to the
541
570
  model. Capped at 120 requests an hour per project; 502 means the model was
542
571
  unreachable.
543
572
 
573
+ #### The mail itself: read, reply, download
574
+
575
+ These read correspondence from people outside your project, so they need
576
+ `mailbox-conversations:read`, and answering needs `mailbox-conversations:reply`.
577
+ Both scopes are **opt-in only**: a key holds one only if it was ticked by name
578
+ when the key was created, and neither the legacy full-access key nor the "Full
579
+ access" preset carries it. An existing key gets 403 until you mint one that does.
580
+
581
+ ```python
582
+ for thread in sendly.mailboxes.iter_list_conversations("mb_123", {"status": "open"}):
583
+ messages = sendly.mailboxes.get_conversation("mb_123", thread["id"]) # oldest first
584
+
585
+ sendly.mailboxes.reply_to_conversation("mb_123", thread_id, {"body": "Sorted, thanks."})
586
+
587
+ file = sendly.mailboxes.download_attachment("mb_123", attachment_id)
588
+ # file.data is bytes; file.content_type is the SENDER's claim.
589
+ ```
590
+
591
+ A reply names neither the recipient nor the sender: it answers whoever last wrote
592
+ in, from the mailbox's own address, in the same thread. Message bodies and
593
+ attachments were written by the sender, so treat `htmlBody`, the bytes and their
594
+ content type as untrusted. Reading marks nothing as read.
595
+
544
596
  ### Templates and snippets
545
597
 
546
598
  ```python
@@ -8,13 +8,13 @@ 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 = "1.1.0"
12
- description = "Official Sendly Python SDK"
11
+ version = "1.3.0"
12
+ description = "Official Python SDK for Sendly (sendly.now), the email API and platform for developers by Devino Solutions: transactional email, contacts, templates and webhooks."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.10"
15
15
  license = { text = "MIT" }
16
16
  authors = [{ name = "Devino Solutions", email = "dev@devino.ca" }]
17
- keywords = ["sendly", "email", "api", "sdk", "transactional-email", "webhooks"]
17
+ keywords = ["sendly", "sendly.now", "email", "email-api", "api", "sdk", "transactional-email", "webhooks"]
18
18
  dependencies = ["httpx==0.28.1"]
19
19
  classifiers = [
20
20
  "Development Status :: 4 - Beta",
@@ -50,6 +50,7 @@ from sendly.resources.validation import ValidationResource
50
50
  from sendly.resources.verify import VerifyResource
51
51
  from sendly.resources.webhooks import WebhooksResource
52
52
  from sendly.resources.workflows import WorkflowsResource
53
+ from sendly.types import SendlyFile
53
54
  from sendly.webhook_utils import DEFAULT_TOLERANCE_MS, construct_event, verify_signature
54
55
 
55
56
  __version__ = SDK_VERSION
@@ -74,6 +75,7 @@ __all__ = [
74
75
  "SendlyConflictError",
75
76
  "SendlyConnectionError",
76
77
  "SendlyError",
78
+ "SendlyFile",
77
79
  "SendlyNotFoundError",
78
80
  "SendlyPermissionError",
79
81
  "SendlyRateLimitError",
@@ -53,6 +53,7 @@ from sendly.resources.validation import ValidationResource
53
53
  from sendly.resources.verify import VerifyResource
54
54
  from sendly.resources.webhooks import WebhooksResource
55
55
  from sendly.resources.workflows import WorkflowsResource
56
+ from sendly.types import SendlyFile
56
57
 
57
58
  if TYPE_CHECKING:
58
59
  from collections.abc import Mapping
@@ -63,7 +64,7 @@ if TYPE_CHECKING:
63
64
  __all__ = ["DEFAULT_BASE_URL", "SDK_VERSION", "Sendly"]
64
65
 
65
66
  #: Package version. Kept in sync with ``pyproject.toml``.
66
- SDK_VERSION = "1.1.0"
67
+ SDK_VERSION = "1.3.0"
67
68
 
68
69
  #: Default production API base. Override via ``base_url`` for staging/self-hosted.
69
70
  DEFAULT_BASE_URL = "https://api.sendly.now"
@@ -174,30 +175,7 @@ class Sendly:
174
175
  success body, or ``None`` for 204 / ``no_content``. Errors are raised as
175
176
  :class:`SendlyError` subclasses based on status.
176
177
  """
177
- url = self._build_url(path, query)
178
- request_headers: dict[str, str] = {
179
- "Authorization": f"Bearer {self._api_key}",
180
- "Accept": "application/json",
181
- "User-Agent": USER_AGENT,
182
- **self._default_headers,
183
- **(dict(headers) if headers else {}),
184
- }
185
-
186
- content: str | None = None
187
- if body is not None:
188
- request_headers["Content-Type"] = "application/json"
189
- content = json.dumps(body)
190
-
191
- try:
192
- response = self._client.request(
193
- method,
194
- url,
195
- content=content,
196
- headers=request_headers,
197
- timeout=self._timeout,
198
- )
199
- except httpx.HTTPError as exc:
200
- raise SendlyConnectionError(f"Sendly request failed: {exc}", exc) from exc
178
+ response = self._send(method=method, path=path, body=body, query=query, headers=headers)
201
179
 
202
180
  # 204 No Content or caller-forced no-content (DELETE endpoints, which
203
181
  # now respond 200 with an id body the SDK intentionally discards).
@@ -229,6 +207,67 @@ class Sendly:
229
207
 
230
208
  return parsed
231
209
 
210
+ def request_bytes(
211
+ self,
212
+ *,
213
+ method: str,
214
+ path: str,
215
+ query: Query | None = None,
216
+ headers: Mapping[str, str] | None = None,
217
+ ) -> SendlyFile:
218
+ """Like :meth:`request`, for an operation whose success body is a file.
219
+
220
+ Returns the raw bytes with the response's own ``Content-Type`` and
221
+ ``Content-Disposition``. A failure is still a JSON error document and is
222
+ raised as the same :class:`SendlyError` subclasses. Reading the body as
223
+ text, as :meth:`request` does, would mangle any file that is not UTF-8.
224
+ """
225
+ response = self._send(
226
+ method=method, path=path, query=query, headers={"Accept": "*/*", **(headers or {})}
227
+ )
228
+ if not response.is_success:
229
+ self._raise_for_error(response)
230
+ return SendlyFile(
231
+ data=response.content,
232
+ content_type=response.headers.get("content-type"),
233
+ content_disposition=response.headers.get("content-disposition"),
234
+ )
235
+
236
+ def _send(
237
+ self,
238
+ *,
239
+ method: str,
240
+ path: str,
241
+ body: Body | None = None,
242
+ query: Query | None = None,
243
+ headers: Mapping[str, str] | None = None,
244
+ ) -> httpx.Response:
245
+ """Issue the request; errors and parsing are the caller's."""
246
+ url = self._build_url(path, query)
247
+ request_headers: dict[str, str] = {
248
+ "Authorization": f"Bearer {self._api_key}",
249
+ "Accept": "application/json",
250
+ "User-Agent": USER_AGENT,
251
+ **self._default_headers,
252
+ **(dict(headers) if headers else {}),
253
+ }
254
+
255
+ content: str | None = None
256
+ if body is not None:
257
+ request_headers["Content-Type"] = "application/json"
258
+ content = json.dumps(body)
259
+
260
+ try:
261
+ return self._client.request(
262
+ method,
263
+ url,
264
+ content=content,
265
+ headers=request_headers,
266
+ timeout=self._timeout,
267
+ )
268
+ except httpx.HTTPError as exc:
269
+ raise SendlyConnectionError(f"Sendly request failed: {exc}", exc) from exc
270
+
232
271
  def unwrap(self, envelope: Any) -> Any:
233
272
  """Return the ``data`` field of a ``{success, data}`` envelope, else the
234
273
  value unchanged. Centralizing this keeps resource code clean.
@@ -16,6 +16,8 @@ if TYPE_CHECKING:
16
16
  DomainDeletedV1,
17
17
  DomainListResponse,
18
18
  DomainListV1,
19
+ DomainLogoReadinessV1,
20
+ DomainLogoV1,
19
21
  DomainRecord,
20
22
  DomainSetupSession,
21
23
  DomainV1,
@@ -208,3 +210,59 @@ class DomainsResource:
208
210
  method="DELETE", path=f"/api/v1/domains/{encode_path_segment(id)}"
209
211
  )
210
212
  return response
213
+
214
+ def get_logo_v1(self, id: str) -> DomainLogoV1:
215
+ """The logo this identity serves to inboxes (BIMI), the
216
+ ``default._bimi`` TXT record that makes them fetch it (``bimi_record``),
217
+ and a fresh DNS reading of whether they will show it today.
218
+
219
+ The URL never changes, so the record is published once. Until a logo is
220
+ uploaded it serves a default (the domain's initial), and it serves
221
+ nothing until the identity verifies. Providers decide whether to show a
222
+ logo; Gmail also needs a VMC or CMC certificate, which Sendly neither
223
+ issues nor checks. :meth:`get_logo_readiness_v1` is the full checklist.
224
+ """
225
+ response: DomainLogoV1 = self._client.request(
226
+ method="GET", path=f"/api/v1/domains/{encode_path_segment(id)}/logo"
227
+ )
228
+ return response
229
+
230
+ def upload_logo_v1(self, id: str, body: Body) -> DomainLogoV1:
231
+ """Replace the logo with an SVG: ``{"svg": "<svg ...>"}``.
232
+
233
+ It is rebuilt into SVG Tiny PS, the profile BIMI requires: square, shapes
234
+ rather than ``<text>``, nothing referenced outside itself, at most 32 KB
235
+ once rebuilt. A file that cannot be rebuilt answers 422
236
+ ``validation_error`` with a ``detail`` naming what to change, and the
237
+ previous logo stays in place.
238
+ """
239
+ response: DomainLogoV1 = self._client.request(
240
+ method="PUT", path=f"/api/v1/domains/{encode_path_segment(id)}/logo", body=body
241
+ )
242
+ return response
243
+
244
+ def delete_logo_v1(self, id: str) -> DomainLogoV1:
245
+ """Remove the uploaded logo, so the URL serves the default again.
246
+
247
+ Returns the logo now served; removing one that was never uploaded is not
248
+ an error. The DNS record does not change.
249
+ """
250
+ response: DomainLogoV1 = self._client.request(
251
+ method="DELETE", path=f"/api/v1/domains/{encode_path_segment(id)}/logo"
252
+ )
253
+ return response
254
+
255
+ def get_logo_readiness_v1(self, id: str) -> DomainLogoReadinessV1:
256
+ """How ready the identity is to show its logo as the inbox avatar.
257
+
258
+ Each step (DMARC at enforcement on the host and its organizational
259
+ domain, the BIMI record, the logo file, the mark certificate) comes back
260
+ ``met``, ``missing``, ``unknown`` or ``not_applicable`` with a
261
+ plain-English ``reason``, plus one checklist per inbox (Gmail, Yahoo,
262
+ Apple Mail, Fastmail). For the certificate only "the URL answers with a
263
+ PEM file" is checked; its chain is not validated.
264
+ """
265
+ response: DomainLogoReadinessV1 = self._client.request(
266
+ method="GET", path=f"/api/v1/domains/{encode_path_segment(id)}/logo/readiness"
267
+ )
268
+ return response