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.
- {sendly_python-1.1.0 → sendly_python-1.3.0}/.github/workflows/ci.yml +1 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/.github/workflows/release.yml +1 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/CHANGELOG.md +50 -0
- sendly_python-1.1.0/README.md → sendly_python-1.3.0/PKG-INFO +118 -2
- sendly_python-1.1.0/PKG-INFO → sendly_python-1.3.0/README.md +86 -34
- {sendly_python-1.1.0 → sendly_python-1.3.0}/pyproject.toml +3 -3
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/__init__.py +2 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/client.py +64 -25
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/domains.py +58 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/mailboxes.py +116 -3
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/types.py +30 -1
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/fixtures/openapi.json +32594 -11242
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_contract.py +179 -8
- sendly_python-1.3.0/tests/test_domain_logo.py +81 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_emails.py +14 -0
- sendly_python-1.3.0/tests/test_mailbox_conversations.py +132 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/.gitignore +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/LICENSE +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/scripts/sync_spec.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/errors.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/py.typed +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/__init__.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/_helpers.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/_pagination.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/analytics.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/campaigns.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/contacts.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/deliverability.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/emails.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/events.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/lists.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/projects.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/segments.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/snippets.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/suppression.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/templates.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/topics.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/usage.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/validation.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/verify.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/webhooks.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/resources/workflows.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/src/sendly/webhook_utils.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/support.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_analytics.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_campaigns.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_client.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_contacts.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_deliverability.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_domains.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_errors_problem.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_events.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_lists.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_mailboxes.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_projects.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_segments.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_snippets.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_suppression.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_templates.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_topics.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_usage.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_validation.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_verify.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_webhook_verify.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_webhooks.py +0 -0
- {sendly_python-1.1.0 → sendly_python-1.3.0}/tests/test_workflows.py +0 -0
|
@@ -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
|
-
|
|
456
|
-
|
|
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
|
-
|
|
488
|
-
|
|
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.
|
|
12
|
-
description = "Official Sendly
|
|
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.
|
|
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
|
-
|
|
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
|