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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {sendly_python-1.1.0 → sendly_python-1.2.0}/.github/workflows/ci.yml +1 -0
  2. {sendly_python-1.1.0 → sendly_python-1.2.0}/.github/workflows/release.yml +1 -0
  3. {sendly_python-1.1.0 → sendly_python-1.2.0}/CHANGELOG.md +29 -0
  4. sendly_python-1.1.0/README.md → sendly_python-1.2.0/PKG-INFO +98 -2
  5. sendly_python-1.1.0/PKG-INFO → sendly_python-1.2.0/README.md +66 -34
  6. {sendly_python-1.1.0 → sendly_python-1.2.0}/pyproject.toml +3 -3
  7. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/__init__.py +2 -0
  8. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/client.py +64 -25
  9. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/resources/domains.py +58 -0
  10. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/resources/mailboxes.py +116 -3
  11. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/types.py +25 -0
  12. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/fixtures/openapi.json +17883 -11005
  13. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/test_contract.py +44 -7
  14. sendly_python-1.2.0/tests/test_domain_logo.py +81 -0
  15. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/test_emails.py +14 -0
  16. sendly_python-1.2.0/tests/test_mailbox_conversations.py +132 -0
  17. {sendly_python-1.1.0 → sendly_python-1.2.0}/.gitignore +0 -0
  18. {sendly_python-1.1.0 → sendly_python-1.2.0}/LICENSE +0 -0
  19. {sendly_python-1.1.0 → sendly_python-1.2.0}/scripts/sync_spec.py +0 -0
  20. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/errors.py +0 -0
  21. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/py.typed +0 -0
  22. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/resources/__init__.py +0 -0
  23. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/resources/_helpers.py +0 -0
  24. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/resources/_pagination.py +0 -0
  25. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/resources/analytics.py +0 -0
  26. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/resources/campaigns.py +0 -0
  27. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/resources/contacts.py +0 -0
  28. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/resources/deliverability.py +0 -0
  29. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/resources/emails.py +0 -0
  30. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/resources/events.py +0 -0
  31. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/resources/lists.py +0 -0
  32. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/resources/projects.py +0 -0
  33. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/resources/segments.py +0 -0
  34. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/resources/snippets.py +0 -0
  35. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/resources/suppression.py +0 -0
  36. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/resources/templates.py +0 -0
  37. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/resources/topics.py +0 -0
  38. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/resources/usage.py +0 -0
  39. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/resources/validation.py +0 -0
  40. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/resources/verify.py +0 -0
  41. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/resources/webhooks.py +0 -0
  42. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/resources/workflows.py +0 -0
  43. {sendly_python-1.1.0 → sendly_python-1.2.0}/src/sendly/webhook_utils.py +0 -0
  44. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/support.py +0 -0
  45. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/test_analytics.py +0 -0
  46. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/test_campaigns.py +0 -0
  47. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/test_client.py +0 -0
  48. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/test_contacts.py +0 -0
  49. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/test_deliverability.py +0 -0
  50. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/test_domains.py +0 -0
  51. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/test_errors_problem.py +0 -0
  52. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/test_events.py +0 -0
  53. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/test_lists.py +0 -0
  54. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/test_mailboxes.py +0 -0
  55. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/test_projects.py +0 -0
  56. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/test_segments.py +0 -0
  57. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/test_snippets.py +0 -0
  58. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/test_suppression.py +0 -0
  59. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/test_templates.py +0 -0
  60. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/test_topics.py +0 -0
  61. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/test_usage.py +0 -0
  62. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/test_validation.py +0 -0
  63. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/test_verify.py +0 -0
  64. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/test_webhook_verify.py +0 -0
  65. {sendly_python-1.1.0 → sendly_python-1.2.0}/tests/test_webhooks.py +0 -0
  66. {sendly_python-1.1.0 → sendly_python-1.2.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,35 @@
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.2.0] - 2026-11-03
9
+
10
+ ### Added
11
+
12
+ - **Mailbox mail.** `mailboxes.list_conversations` / `iter_list_conversations`,
13
+ `get_conversation`, `reply_to_conversation` and `download_attachment`, under
14
+ the opt-in `mailbox-conversations:read` / `:reply` scopes, which no existing
15
+ key carries.
16
+ - **The sender logo (BIMI).** `domains.get_logo_v1`, `upload_logo_v1`,
17
+ `delete_logo_v1` and `get_logo_readiness_v1`.
18
+ - **`client.request_bytes`** for an operation that answers a file. It returns a
19
+ `SendlyFile(data: bytes, content_type, content_disposition)` and raises the
20
+ same typed errors. `request` reads the body as text, which mangles a binary file.
21
+ - **`emails.send` takes `listUnsubscribe`**, the https URL of your own one-click
22
+ unsubscribe endpoint. A transactional send carries no unsubscribe of Sendly's,
23
+ because Sendly cannot know what the reader would be opting out of; with this
24
+ field Sendly writes `List-Unsubscribe: <url>` and
25
+ `List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058), so the mail
26
+ client shows its own unsubscribe button. The two headers stay reserved in
27
+ `headers`. The server answers `422` for a value that is not https, is over
28
+ 2048 characters, or contains whitespace, a comma or angle brackets. Campaign
29
+ and workflow sends ignore it and keep Sendly's own link.
30
+ - **The vendored spec is re-synced** from the platform contract. The new mailbox
31
+ conversation and domain-logo operations are listed as not yet implemented, and
32
+ the provider compat dialects under `/api/compat/` are excluded by path: those
33
+ are for the other providers' own SDKs.
34
+
6
35
  ## [1.1.0] - 2026-09-05
7
36
 
8
37
  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.2.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
 
@@ -432,6 +483,28 @@ neither is a spelling of the other. `verify_v1` verifies nothing itself:
432
483
  verification happens in the domain's DNS when its owner publishes the DKIM
433
484
  records SES minted at creation, and this call asks SES what it currently sees.
434
485
 
486
+ #### The sender logo (BIMI)
487
+
488
+ Each identity serves a logo that BIMI-capable inboxes show beside its mail, once
489
+ its DNS is ready:
490
+
491
+ ```python
492
+ logo = sendly.domains.get_logo_v1("d_123")
493
+ print(logo["bimi_record"]) # publish this TXT record once; the URL never changes
494
+
495
+ sendly.domains.upload_logo_v1("d_123", {"svg": svg_source}) # square, shapes not <text>
496
+ for item in sendly.domains.get_logo_readiness_v1("d_123")["items"]:
497
+ print(item["key"], item["status"], item["reason"])
498
+ ```
499
+
500
+ The upload is rebuilt into SVG Tiny PS, the profile BIMI requires. A file that
501
+ cannot be rebuilt (text not converted to outlines, a raster image, a canvas that
502
+ is not square, over 32 KB once cleaned) is a 422 `validation_error` naming what to
503
+ change, and the previous logo stays. `delete_logo_v1` goes back to the default
504
+ (the domain's initial). Providers still decide whether to show a logo, and Gmail
505
+ and Apple Mail need a VMC or CMC certificate, which Sendly neither issues nor
506
+ validates.
507
+
435
508
  ### Mailboxes
436
509
 
437
510
  Receiving mailboxes on the project's verified domains. The reads are reads; the
@@ -452,8 +525,8 @@ for pw in sendly.mailboxes.list_app_passwords("mb_123"):
452
525
  print(pw["name"], pw["lastFour"], pw["lastUsedAt"])
453
526
  ```
454
527
 
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
528
+ `list` and `get` describe the mailboxes themselves, never their contents (the
529
+ mail is below, under a scope of its own). The mailbox **password** is never returned by
457
530
  any of these reads; mailbox credentials are app passwords, created from the
458
531
  dashboard and shown once. `list_app_passwords` returns only the passwords that
459
532
  are still active — a revoked one drops out, so this is not an audit history.
@@ -509,6 +582,29 @@ treated strictly as data describing what to write, never as instructions to the
509
582
  model. Capped at 120 requests an hour per project; 502 means the model was
510
583
  unreachable.
511
584
 
585
+ #### The mail itself: read, reply, download
586
+
587
+ These read correspondence from people outside your project, so they need
588
+ `mailbox-conversations:read`, and answering needs `mailbox-conversations:reply`.
589
+ Both scopes are **opt-in only**: a key holds one only if it was ticked by name
590
+ when the key was created, and neither the legacy full-access key nor the "Full
591
+ access" preset carries it. An existing key gets 403 until you mint one that does.
592
+
593
+ ```python
594
+ for thread in sendly.mailboxes.iter_list_conversations("mb_123", {"status": "open"}):
595
+ messages = sendly.mailboxes.get_conversation("mb_123", thread["id"]) # oldest first
596
+
597
+ sendly.mailboxes.reply_to_conversation("mb_123", thread_id, {"body": "Sorted, thanks."})
598
+
599
+ file = sendly.mailboxes.download_attachment("mb_123", attachment_id)
600
+ # file.data is bytes; file.content_type is the SENDER's claim.
601
+ ```
602
+
603
+ A reply names neither the recipient nor the sender: it answers whoever last wrote
604
+ in, from the mailbox's own address, in the same thread. Message bodies and
605
+ attachments were written by the sender, so treat `htmlBody`, the bytes and their
606
+ content type as untrusted. Reading marks nothing as read.
607
+
512
608
  ### Templates and snippets
513
609
 
514
610
  ```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
 
@@ -464,6 +451,28 @@ neither is a spelling of the other. `verify_v1` verifies nothing itself:
464
451
  verification happens in the domain's DNS when its owner publishes the DKIM
465
452
  records SES minted at creation, and this call asks SES what it currently sees.
466
453
 
454
+ #### The sender logo (BIMI)
455
+
456
+ Each identity serves a logo that BIMI-capable inboxes show beside its mail, once
457
+ its DNS is ready:
458
+
459
+ ```python
460
+ logo = sendly.domains.get_logo_v1("d_123")
461
+ print(logo["bimi_record"]) # publish this TXT record once; the URL never changes
462
+
463
+ sendly.domains.upload_logo_v1("d_123", {"svg": svg_source}) # square, shapes not <text>
464
+ for item in sendly.domains.get_logo_readiness_v1("d_123")["items"]:
465
+ print(item["key"], item["status"], item["reason"])
466
+ ```
467
+
468
+ The upload is rebuilt into SVG Tiny PS, the profile BIMI requires. A file that
469
+ cannot be rebuilt (text not converted to outlines, a raster image, a canvas that
470
+ is not square, over 32 KB once cleaned) is a 422 `validation_error` naming what to
471
+ change, and the previous logo stays. `delete_logo_v1` goes back to the default
472
+ (the domain's initial). Providers still decide whether to show a logo, and Gmail
473
+ and Apple Mail need a VMC or CMC certificate, which Sendly neither issues nor
474
+ validates.
475
+
467
476
  ### Mailboxes
468
477
 
469
478
  Receiving mailboxes on the project's verified domains. The reads are reads; the
@@ -484,8 +493,8 @@ for pw in sendly.mailboxes.list_app_passwords("mb_123"):
484
493
  print(pw["name"], pw["lastFour"], pw["lastUsedAt"])
485
494
  ```
486
495
 
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
496
+ `list` and `get` describe the mailboxes themselves, never their contents (the
497
+ mail is below, under a scope of its own). The mailbox **password** is never returned by
489
498
  any of these reads; mailbox credentials are app passwords, created from the
490
499
  dashboard and shown once. `list_app_passwords` returns only the passwords that
491
500
  are still active — a revoked one drops out, so this is not an audit history.
@@ -541,6 +550,29 @@ treated strictly as data describing what to write, never as instructions to the
541
550
  model. Capped at 120 requests an hour per project; 502 means the model was
542
551
  unreachable.
543
552
 
553
+ #### The mail itself: read, reply, download
554
+
555
+ These read correspondence from people outside your project, so they need
556
+ `mailbox-conversations:read`, and answering needs `mailbox-conversations:reply`.
557
+ Both scopes are **opt-in only**: a key holds one only if it was ticked by name
558
+ when the key was created, and neither the legacy full-access key nor the "Full
559
+ access" preset carries it. An existing key gets 403 until you mint one that does.
560
+
561
+ ```python
562
+ for thread in sendly.mailboxes.iter_list_conversations("mb_123", {"status": "open"}):
563
+ messages = sendly.mailboxes.get_conversation("mb_123", thread["id"]) # oldest first
564
+
565
+ sendly.mailboxes.reply_to_conversation("mb_123", thread_id, {"body": "Sorted, thanks."})
566
+
567
+ file = sendly.mailboxes.download_attachment("mb_123", attachment_id)
568
+ # file.data is bytes; file.content_type is the SENDER's claim.
569
+ ```
570
+
571
+ A reply names neither the recipient nor the sender: it answers whoever last wrote
572
+ in, from the mailbox's own address, in the same thread. Message bodies and
573
+ attachments were written by the sender, so treat `htmlBody`, the bytes and their
574
+ content type as untrusted. Reading marks nothing as read.
575
+
544
576
  ### Templates and snippets
545
577
 
546
578
  ```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.2.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.2.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