sendly-python 0.2.0__py3-none-any.whl → 1.0.0__py3-none-any.whl
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/__init__.py +7 -1
- sendly/client.py +6 -1
- sendly/resources/domains.py +16 -0
- sendly/resources/emails.py +50 -3
- sendly/resources/mailboxes.py +75 -0
- sendly/resources/projects.py +33 -0
- sendly/types.py +24 -0
- {sendly_python-0.2.0.dist-info → sendly_python-1.0.0.dist-info}/METADATA +180 -10
- {sendly_python-0.2.0.dist-info → sendly_python-1.0.0.dist-info}/RECORD +11 -9
- {sendly_python-0.2.0.dist-info → sendly_python-1.0.0.dist-info}/WHEEL +0 -0
- {sendly_python-0.2.0.dist-info → sendly_python-1.0.0.dist-info}/licenses/LICENSE +0 -0
sendly/__init__.py
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
Example:
|
|
4
4
|
>>> from sendly import Sendly
|
|
5
5
|
>>> sendly = Sendly() # reads SENDLY_API_KEY
|
|
6
|
-
>>> sendly.emails.send(
|
|
6
|
+
>>> receipt = sendly.emails.send(
|
|
7
7
|
... {"from": "a@b.com", "to": "c@d.com", "subject": "hi", "body": "<p>hi</p>"}
|
|
8
8
|
... )
|
|
9
|
+
>>> receipt["status"] # a real delivery state; poll emails.get(receipt["id"])
|
|
10
|
+
'PENDING'
|
|
9
11
|
|
|
10
12
|
The same client also speaks the ``/api/v1`` surface — campaigns, segments,
|
|
11
13
|
workflows, analytics, usage, and the v1 event methods:
|
|
@@ -35,6 +37,8 @@ from sendly.resources.domains import DomainsResource
|
|
|
35
37
|
from sendly.resources.emails import EmailsResource
|
|
36
38
|
from sendly.resources.events import EventsResource
|
|
37
39
|
from sendly.resources.lists import ListsResource
|
|
40
|
+
from sendly.resources.mailboxes import MailboxesResource
|
|
41
|
+
from sendly.resources.projects import ProjectsResource
|
|
38
42
|
from sendly.resources.segments import SegmentsResource
|
|
39
43
|
from sendly.resources.suppression import SuppressionResource
|
|
40
44
|
from sendly.resources.templates import TemplatesResource
|
|
@@ -57,6 +61,8 @@ __all__ = [
|
|
|
57
61
|
"EmailsResource",
|
|
58
62
|
"EventsResource",
|
|
59
63
|
"ListsResource",
|
|
64
|
+
"MailboxesResource",
|
|
65
|
+
"ProjectsResource",
|
|
60
66
|
"SegmentsResource",
|
|
61
67
|
"Sendly",
|
|
62
68
|
"SendlyAuthenticationError",
|
sendly/client.py
CHANGED
|
@@ -40,6 +40,8 @@ from sendly.resources.domains import DomainsResource
|
|
|
40
40
|
from sendly.resources.emails import EmailsResource
|
|
41
41
|
from sendly.resources.events import EventsResource
|
|
42
42
|
from sendly.resources.lists import ListsResource
|
|
43
|
+
from sendly.resources.mailboxes import MailboxesResource
|
|
44
|
+
from sendly.resources.projects import ProjectsResource
|
|
43
45
|
from sendly.resources.segments import SegmentsResource
|
|
44
46
|
from sendly.resources.suppression import SuppressionResource
|
|
45
47
|
from sendly.resources.templates import TemplatesResource
|
|
@@ -57,7 +59,7 @@ if TYPE_CHECKING:
|
|
|
57
59
|
__all__ = ["DEFAULT_BASE_URL", "SDK_VERSION", "Sendly"]
|
|
58
60
|
|
|
59
61
|
#: Package version. Kept in sync with ``pyproject.toml``.
|
|
60
|
-
SDK_VERSION = "0.
|
|
62
|
+
SDK_VERSION = "1.0.0"
|
|
61
63
|
|
|
62
64
|
#: Default production API base. Override via ``base_url`` for staging/self-hosted.
|
|
63
65
|
DEFAULT_BASE_URL = "https://api.sendly.now"
|
|
@@ -135,6 +137,8 @@ class Sendly:
|
|
|
135
137
|
self.webhooks = WebhooksResource(self)
|
|
136
138
|
self.suppression = SuppressionResource(self)
|
|
137
139
|
self.lists = ListsResource(self)
|
|
140
|
+
# Reads only -- the mailbox writes need a user, which an API key is not.
|
|
141
|
+
self.mailboxes = MailboxesResource(self)
|
|
138
142
|
# /api/v1 surface. Same client, same auth; bare resource bodies instead
|
|
139
143
|
# of the legacy {success, data} envelope, and RFC 9457 problem errors.
|
|
140
144
|
self.campaigns = CampaignsResource(self)
|
|
@@ -142,6 +146,7 @@ class Sendly:
|
|
|
142
146
|
self.workflows = WorkflowsResource(self)
|
|
143
147
|
self.analytics = AnalyticsResource(self)
|
|
144
148
|
self.usage = UsageResource(self)
|
|
149
|
+
self.projects = ProjectsResource(self)
|
|
145
150
|
|
|
146
151
|
def request(
|
|
147
152
|
self,
|
sendly/resources/domains.py
CHANGED
|
@@ -12,6 +12,7 @@ if TYPE_CHECKING:
|
|
|
12
12
|
Body,
|
|
13
13
|
DomainListResponse,
|
|
14
14
|
DomainRecord,
|
|
15
|
+
DomainSetupSession,
|
|
15
16
|
DomainVerificationStatus,
|
|
16
17
|
)
|
|
17
18
|
|
|
@@ -62,6 +63,21 @@ class DomainsResource:
|
|
|
62
63
|
status: DomainVerificationStatus = self._client.unwrap(envelope)
|
|
63
64
|
return status
|
|
64
65
|
|
|
66
|
+
def start_setup(self, id: str) -> DomainSetupSession:
|
|
67
|
+
"""Start the guided DNS setup hand-off for a domain.
|
|
68
|
+
|
|
69
|
+
Returns the session as the route returns it: a ``connectUrl`` to open in
|
|
70
|
+
a browser, the ``token`` that url carries, and ``expiresAt``. Nothing is
|
|
71
|
+
derived or reshaped -- finishing setup means a person visiting that url
|
|
72
|
+
and authorising the change at their registrar, so the SDK's job is to
|
|
73
|
+
hand back the link, not to model the flow behind it.
|
|
74
|
+
"""
|
|
75
|
+
envelope = self._client.request(
|
|
76
|
+
method="POST", path=f"/api/domains/{encode_path_segment(id)}/dodomain-session"
|
|
77
|
+
)
|
|
78
|
+
session: DomainSetupSession = self._client.unwrap(envelope)
|
|
79
|
+
return session
|
|
80
|
+
|
|
65
81
|
def delete(self, id: str) -> None:
|
|
66
82
|
"""Delete a domain."""
|
|
67
83
|
self._client.request(method="DELETE", path=f"/api/domains/{encode_path_segment(id)}")
|
sendly/resources/emails.py
CHANGED
|
@@ -13,6 +13,8 @@ if TYPE_CHECKING:
|
|
|
13
13
|
Body,
|
|
14
14
|
EmailGetResponse,
|
|
15
15
|
EmailListResponse,
|
|
16
|
+
EmailTestV1,
|
|
17
|
+
EmailV1,
|
|
16
18
|
Query,
|
|
17
19
|
SendEmailData,
|
|
18
20
|
SuccessEmpty,
|
|
@@ -25,12 +27,42 @@ class EmailsResource:
|
|
|
25
27
|
def __init__(self, client: Sendly) -> None:
|
|
26
28
|
self._client = client
|
|
27
29
|
|
|
28
|
-
def send(
|
|
30
|
+
def send(self, body: Body, *, idempotency_key: str | None = None) -> EmailV1:
|
|
31
|
+
"""Send one transactional email.
|
|
32
|
+
|
|
33
|
+
Posts to the versioned ``POST /api/v1/emails`` and returns the bare
|
|
34
|
+
receipt it answers 202 with: ``{id, status, to, from}``. ``status`` is a
|
|
35
|
+
real delivery state -- poll ``emails.get(id)`` for the events behind
|
|
36
|
+
it. Takes a single recipient; use ``cc``/``bcc`` to copy others.
|
|
37
|
+
|
|
38
|
+
Pass ``idempotency_key`` (1-255 chars) to dedupe replays for 24h.
|
|
39
|
+
|
|
40
|
+
Before 1.0 this posted to the legacy ``POST /api/emails``, which
|
|
41
|
+
answered with row ids and no delivery status and fanned an array
|
|
42
|
+
``to`` out to several recipients. That behaviour is
|
|
43
|
+
:meth:`send_legacy`, unchanged.
|
|
44
|
+
"""
|
|
45
|
+
response: EmailV1 = self._client.request(
|
|
46
|
+
method="POST",
|
|
47
|
+
path="/api/v1/emails",
|
|
48
|
+
body=body,
|
|
49
|
+
headers=idempotency_headers(idempotency_key),
|
|
50
|
+
)
|
|
51
|
+
return response
|
|
52
|
+
|
|
53
|
+
def send_legacy(
|
|
29
54
|
self, body: Body, *, idempotency_key: str | None = None
|
|
30
55
|
) -> SendEmailData | list[SendEmailData]:
|
|
31
|
-
"""
|
|
56
|
+
"""The pre-1.0 :meth:`send`: the legacy ``POST /api/emails``.
|
|
32
57
|
|
|
33
|
-
|
|
58
|
+
Returns the envelope's ``data``, ``{emails, timestamp}``, where
|
|
59
|
+
``emails`` has one entry per recipient (an array ``to`` fans out to
|
|
60
|
+
several). Each entry is ``{contact: {id, email}, email}`` -- ``email``
|
|
61
|
+
being the id of the queued email record for that recipient. Reports no
|
|
62
|
+
delivery status of its own.
|
|
63
|
+
|
|
64
|
+
Kept as the escape hatch for a caller that depends on the fan-out or on
|
|
65
|
+
the envelope shape. New code should use :meth:`send`.
|
|
34
66
|
"""
|
|
35
67
|
envelope = self._client.request(
|
|
36
68
|
method="POST",
|
|
@@ -41,6 +73,21 @@ class EmailsResource:
|
|
|
41
73
|
data: SendEmailData | list[SendEmailData] = self._client.unwrap(envelope)
|
|
42
74
|
return data
|
|
43
75
|
|
|
76
|
+
def send_test(self, body: Body) -> EmailTestV1:
|
|
77
|
+
"""Send a test email from the project's sandbox address.
|
|
78
|
+
|
|
79
|
+
Goes nowhere real: the sandbox address is the SENDER, resolved
|
|
80
|
+
server-side (naming a ``from`` is refused), and the mail lands in the
|
|
81
|
+
project owner's own verified inbox. This exercises rendering and the
|
|
82
|
+
send path without touching a live recipient or a sending reputation.
|
|
83
|
+
Read ``projects.get()["sandbox_address"]`` to know what it sends from
|
|
84
|
+
-- the response's ``sandbox: true`` says only that it was one.
|
|
85
|
+
"""
|
|
86
|
+
response: EmailTestV1 = self._client.request(
|
|
87
|
+
method="POST", path="/api/v1/emails/test", body=body
|
|
88
|
+
)
|
|
89
|
+
return response
|
|
90
|
+
|
|
44
91
|
def batch(self, body: Body, *, idempotency_key: str | None = None) -> BatchSendResponse:
|
|
45
92
|
"""Send a batch (up to 100) of transactional emails in one call."""
|
|
46
93
|
response: BatchSendResponse = self._client.request(
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Mailboxes resource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from sendly.resources._helpers import encode_path_segment
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from sendly.client import Sendly
|
|
11
|
+
from sendly.types import AppPasswordList, MailboxDetail, MailboxList
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MailboxesResource:
|
|
15
|
+
"""Receiving mailboxes on the project's verified domains.
|
|
16
|
+
|
|
17
|
+
Read only, and deliberately so. Creating or deleting a mailbox, and minting
|
|
18
|
+
or revoking an app password, all resolve the acting project admin from the
|
|
19
|
+
session user. An API-key context carries no user, so those routes answer
|
|
20
|
+
401 to any key however broad its scopes -- the contract records this by
|
|
21
|
+
publishing ``SessionAuth`` without ``ApiKeyAuth`` on them. This SDK
|
|
22
|
+
authenticates only with API keys, so such methods could never succeed; they
|
|
23
|
+
are listed in ``tests/test_contract.py``'s ``NOT_SDK_CALLABLE`` instead.
|
|
24
|
+
|
|
25
|
+
The three reads below are the opposite case: their membership check is
|
|
26
|
+
conditional, so a key really can call them.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, client: Sendly) -> None:
|
|
30
|
+
self._client = client
|
|
31
|
+
|
|
32
|
+
def list(self) -> MailboxList:
|
|
33
|
+
"""Every mailbox on the project's domains, newest first.
|
|
34
|
+
|
|
35
|
+
Not paginated. A project is capped at 10 mailboxes, but the cap counts
|
|
36
|
+
only those holding (or mid-way to holding) a real account --
|
|
37
|
+
``PROVISIONING``, ``ACTIVE`` and ``SUSPENDED``. ``FAILED`` rows are
|
|
38
|
+
excluded from it deliberately, so that a Stalwart outage cannot spend a
|
|
39
|
+
project's whole allowance, and they are still returned here: a project
|
|
40
|
+
with a run of failed provisions can therefore list more than 10.
|
|
41
|
+
|
|
42
|
+
This lists the mailboxes themselves, never their contents: received
|
|
43
|
+
messages are not part of the public API.
|
|
44
|
+
"""
|
|
45
|
+
envelope = self._client.request(method="GET", path="/api/mailboxes")
|
|
46
|
+
records: MailboxList = self._client.unwrap(envelope)
|
|
47
|
+
return records
|
|
48
|
+
|
|
49
|
+
def get(self, id: str) -> MailboxDetail:
|
|
50
|
+
"""One mailbox, with the IMAP/SMTP host, port and username to connect with.
|
|
51
|
+
|
|
52
|
+
The password is not included and is never returned here -- mailbox
|
|
53
|
+
credentials are app passwords, created from the dashboard and shown once.
|
|
54
|
+
"""
|
|
55
|
+
envelope = self._client.request(
|
|
56
|
+
method="GET", path=f"/api/mailboxes/{encode_path_segment(id)}"
|
|
57
|
+
)
|
|
58
|
+
detail: MailboxDetail = self._client.unwrap(envelope)
|
|
59
|
+
return detail
|
|
60
|
+
|
|
61
|
+
def list_app_passwords(self, id: str) -> AppPasswordList:
|
|
62
|
+
"""The app passwords still active on a mailbox -- metadata only.
|
|
63
|
+
|
|
64
|
+
Revoked ones are not returned: the route filters on ``revokedAt: null``,
|
|
65
|
+
so this is the set that can currently authenticate, not an audit
|
|
66
|
+
history.
|
|
67
|
+
|
|
68
|
+
``lastFour`` is the only fragment of the secret that survives creation,
|
|
69
|
+
so this identifies a credential without being able to reconstruct it.
|
|
70
|
+
"""
|
|
71
|
+
envelope = self._client.request(
|
|
72
|
+
method="GET", path=f"/api/mailboxes/{encode_path_segment(id)}/app-passwords"
|
|
73
|
+
)
|
|
74
|
+
records: AppPasswordList = self._client.unwrap(envelope)
|
|
75
|
+
return records
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Projects resource (``/api/v1``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from sendly.client import Sendly
|
|
9
|
+
from sendly.types import ProjectRecordV1
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProjectsResource:
|
|
13
|
+
"""The project the credential resolves to.
|
|
14
|
+
|
|
15
|
+
There is no ``create`` here. Creating a project resolves the owner from the
|
|
16
|
+
session user and refuses an API key with 401, so it is recorded in
|
|
17
|
+
``tests/test_contract.py``'s ``NOT_SDK_CALLABLE`` rather than shipped as a
|
|
18
|
+
method that cannot work.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, client: Sendly) -> None:
|
|
22
|
+
self._client = client
|
|
23
|
+
|
|
24
|
+
def get(self) -> ProjectRecordV1:
|
|
25
|
+
"""Read the current project.
|
|
26
|
+
|
|
27
|
+
Takes no id: the project is whichever one the API key belongs to.
|
|
28
|
+
Carries ``sandbox_address``, which is where a test send arrives --
|
|
29
|
+
without it a test send is undiscoverable, since the caller cannot say
|
|
30
|
+
where to look for it.
|
|
31
|
+
"""
|
|
32
|
+
response: ProjectRecordV1 = self._client.request(method="GET", path="/api/v1/projects")
|
|
33
|
+
return response
|
sendly/types.py
CHANGED
|
@@ -42,6 +42,11 @@ EmailRecord = JSONDict
|
|
|
42
42
|
EmailListResponse = JSONDict
|
|
43
43
|
EmailGetResponse = JSONDict
|
|
44
44
|
|
|
45
|
+
# The versioned send. Distinct from the legacy aliases above, which post to
|
|
46
|
+
# ``/api/emails`` and answer with row ids and no delivery status.
|
|
47
|
+
EmailV1 = JSONDict
|
|
48
|
+
EmailTestV1 = JSONDict
|
|
49
|
+
|
|
45
50
|
# ---------- Contacts ----------
|
|
46
51
|
|
|
47
52
|
ContactRecord = JSONDict
|
|
@@ -52,6 +57,25 @@ ContactListResponse = JSONDict
|
|
|
52
57
|
DomainRecord = JSONDict
|
|
53
58
|
DomainListResponse = JSONDict
|
|
54
59
|
DomainVerificationStatus = JSONDict
|
|
60
|
+
#: ``{token, connectUrl, expiresAt}`` -- the link a person opens to finish setup.
|
|
61
|
+
DomainSetupSession = JSONDict
|
|
62
|
+
|
|
63
|
+
# ---------- Mailboxes ----------
|
|
64
|
+
|
|
65
|
+
MailboxRecord = JSONDict
|
|
66
|
+
#: A mailbox plus the IMAP/SMTP host, port and username a mail client needs.
|
|
67
|
+
MailboxDetail = JSONDict
|
|
68
|
+
AppPasswordRecord = JSONDict
|
|
69
|
+
#: The list aliases are not decoration: inside ``MailboxesResource`` the name
|
|
70
|
+
#: ``list`` is the resource's own method, so a bare ``list[MailboxRecord]``
|
|
71
|
+
#: annotation resolves to that method and fails type checking. Naming the list
|
|
72
|
+
#: types here sidesteps the shadowing and keeps the annotations readable.
|
|
73
|
+
MailboxList = list[JSONDict]
|
|
74
|
+
AppPasswordList = list[JSONDict]
|
|
75
|
+
|
|
76
|
+
# ---------- Projects (v1) ----------
|
|
77
|
+
|
|
78
|
+
ProjectRecordV1 = JSONDict
|
|
55
79
|
|
|
56
80
|
# ---------- Templates ----------
|
|
57
81
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sendly-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0
|
|
4
4
|
Summary: Official Sendly Python SDK
|
|
5
5
|
Project-URL: Homepage, https://sendly.now
|
|
6
6
|
Project-URL: Documentation, https://docs.sendly.now
|
|
@@ -33,8 +33,8 @@ Description-Content-Type: text/markdown
|
|
|
33
33
|
# Sendly Python SDK
|
|
34
34
|
|
|
35
35
|
Official Python SDK for the [Sendly](https://sendly.now) REST API — transactional
|
|
36
|
-
email, contacts, events, domains, templates, email verification, webhooks,
|
|
37
|
-
suppression.
|
|
36
|
+
email, contacts, events, domains, templates, email verification, webhooks,
|
|
37
|
+
suppression, and mailbox and project reads.
|
|
38
38
|
|
|
39
39
|
[](https://github.com/DevinoSolutions/sendly-python/actions/workflows/ci.yml)
|
|
40
40
|
|
|
@@ -90,7 +90,7 @@ from sendly import Sendly
|
|
|
90
90
|
|
|
91
91
|
sendly = Sendly() # reads SENDLY_API_KEY
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
receipt = sendly.emails.send(
|
|
94
94
|
{
|
|
95
95
|
"from": "hello@yourdomain.com",
|
|
96
96
|
"to": "customer@example.com",
|
|
@@ -98,9 +98,35 @@ result = sendly.emails.send(
|
|
|
98
98
|
"body": "<h1>Thanks for signing up!</h1>",
|
|
99
99
|
}
|
|
100
100
|
)
|
|
101
|
-
|
|
101
|
+
|
|
102
|
+
# `status` is a real delivery state; poll `emails.get(receipt["id"])` for the
|
|
103
|
+
# events behind it.
|
|
104
|
+
print(receipt["id"], receipt["status"])
|
|
102
105
|
```
|
|
103
106
|
|
|
107
|
+
### Upgrading from 0.x
|
|
108
|
+
|
|
109
|
+
**1.0 repoints `emails.send` to the versioned `POST /api/v1/emails`.** It now
|
|
110
|
+
takes one recipient (`cc`/`bcc` copy others) and returns the `202` receipt
|
|
111
|
+
`{id, status, to, from}`, where `status` is a real delivery state. Before 1.0 it
|
|
112
|
+
posted to the legacy `POST /api/emails`, fanned an array `to` out to several
|
|
113
|
+
recipients, and returned `{emails, timestamp}` with no delivery status.
|
|
114
|
+
|
|
115
|
+
The old behaviour is kept, unchanged, as `emails.send_legacy`. Two ways to
|
|
116
|
+
upgrade:
|
|
117
|
+
|
|
118
|
+
- **Keep the old shapes:** rename the call. `send(...)` → `send_legacy(...)`.
|
|
119
|
+
Done.
|
|
120
|
+
- **Take the new default:** read the receipt instead of the envelope
|
|
121
|
+
(`receipt["id"]` / `receipt["status"]` in place of
|
|
122
|
+
`result["emails"][0]["email"]`), send to one recipient per call, and note that
|
|
123
|
+
failures now carry the v1 error fields (`err.error_code` is lowercase,
|
|
124
|
+
`err.request_id` and `err.field_errors` are set) — the exception classes are
|
|
125
|
+
the same, so `except` blocks stand.
|
|
126
|
+
|
|
127
|
+
Nothing else changed shape. See [CHANGELOG.md](./CHANGELOG.md) for the full
|
|
128
|
+
1.0.0 entry.
|
|
129
|
+
|
|
104
130
|
Or pass the key explicitly:
|
|
105
131
|
|
|
106
132
|
```python
|
|
@@ -134,9 +160,15 @@ with Sendly() as sendly:
|
|
|
134
160
|
### Emails
|
|
135
161
|
|
|
136
162
|
```python
|
|
137
|
-
# Single send (pass idempotency_key to dedupe replays for 24h)
|
|
138
|
-
|
|
139
|
-
|
|
163
|
+
# Single send on /api/v1 (pass idempotency_key to dedupe replays for 24h).
|
|
164
|
+
# One recipient in `to`; `cc` / `bcc` copy others. Returns {id, status, to, from}.
|
|
165
|
+
receipt = sendly.emails.send({"from": "a@you.com", "to": "b@them.com", "subject": "Hi", "body": "<p>Hi</p>"},
|
|
166
|
+
idempotency_key="order-42-receipt")
|
|
167
|
+
|
|
168
|
+
# The pre-1.0 send: legacy /api/emails, an array `to` fans out, answers
|
|
169
|
+
# {emails, timestamp} with no delivery status.
|
|
170
|
+
sendly.emails.send_legacy({"from": "a@you.com", "to": ["b@them.com", "c@them.com"],
|
|
171
|
+
"subject": "Hi", "body": "<p>Hi</p>"})
|
|
140
172
|
|
|
141
173
|
# Batch send (up to 100)
|
|
142
174
|
sendly.emails.batch({"emails": [{"from": "a@you.com", "to": "b@them.com", "subject": "Hi", "body": "<p>Hi</p>"}]})
|
|
@@ -180,9 +212,45 @@ sendly.domains.list()
|
|
|
180
212
|
sendly.domains.get("d_123")
|
|
181
213
|
sendly.domains.verify("d_123")
|
|
182
214
|
sendly.domains.get_verification("d_123")
|
|
215
|
+
sendly.domains.start_setup("d_123") # -> {"token", "connectUrl", "expiresAt"}
|
|
183
216
|
sendly.domains.delete("d_123")
|
|
184
217
|
```
|
|
185
218
|
|
|
219
|
+
`start_setup` returns the hand-off as the API returns it. Open `connectUrl` in a
|
|
220
|
+
browser to finish DNS setup at the registrar.
|
|
221
|
+
|
|
222
|
+
### Mailboxes
|
|
223
|
+
|
|
224
|
+
Reads only — see [What the SDK does not expose](#what-the-sdk-does-not-expose).
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
sendly.mailboxes.list() # -> [mailbox, ...], not paginated
|
|
228
|
+
detail = sendly.mailboxes.get("mb_123")
|
|
229
|
+
|
|
230
|
+
# `settings` carries the IMAP and SMTP host, port, security and username.
|
|
231
|
+
print(detail["settings"]["imap"]["host"], detail["settings"]["imap"]["port"])
|
|
232
|
+
|
|
233
|
+
# Metadata only — `lastFour` is the one fragment of the secret that survives
|
|
234
|
+
# creation, so a credential can be identified but not rebuilt.
|
|
235
|
+
for pw in sendly.mailboxes.list_app_passwords("mb_123"):
|
|
236
|
+
print(pw["name"], pw["lastFour"], pw["lastUsedAt"])
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
This lists the mailboxes themselves, never their contents — received messages
|
|
240
|
+
are not part of the public API. The mailbox **password** is never returned by
|
|
241
|
+
any of these reads; mailbox credentials are app passwords, created from the
|
|
242
|
+
dashboard and shown once. `list_app_passwords` returns only the passwords that
|
|
243
|
+
are still active — a revoked one drops out, so this is not an audit history.
|
|
244
|
+
|
|
245
|
+
**The per-project cap is 10 mailboxes.** It counts only those holding, or
|
|
246
|
+
mid-way to holding, a real account — `PROVISIONING`, `ACTIVE` and `SUSPENDED`.
|
|
247
|
+
`FAILED` rows are excluded on purpose, so that a burst of failed provisions
|
|
248
|
+
cannot eat a project's allowance and turn an outage into "you have reached your
|
|
249
|
+
mailbox limit"; they are still returned by `list()`, so a project that has had
|
|
250
|
+
failures can list more than 10. Exceeding the cap is a `409`
|
|
251
|
+
(`SendlyConflictError`) from whatever creates the mailbox — which is not this
|
|
252
|
+
SDK, since mailbox creation needs a signed-in user.
|
|
253
|
+
|
|
186
254
|
### Templates
|
|
187
255
|
|
|
188
256
|
```python
|
|
@@ -316,7 +384,7 @@ Available on the six cursor-paginated listings: `campaigns.iter_list`,
|
|
|
316
384
|
`events.list_names` / `events.stats` return a bounded aggregate rather than a
|
|
317
385
|
cursor, so they have no iterator.
|
|
318
386
|
|
|
319
|
-
### Segments, workflows, events, analytics, usage
|
|
387
|
+
### Segments, workflows, events, analytics, usage, projects
|
|
320
388
|
|
|
321
389
|
```python
|
|
322
390
|
segment = sendly.segments.create({"name": "Power users", "type": "DYNAMIC",
|
|
@@ -343,8 +411,71 @@ sendly.analytics.top_campaigns({"limit": 5})
|
|
|
343
411
|
|
|
344
412
|
usage = sendly.usage.get()
|
|
345
413
|
print(usage["plan"], usage["monthly"])
|
|
414
|
+
|
|
415
|
+
project = sendly.projects.get()
|
|
416
|
+
print(project["sandbox_address"])
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
### Emails: `send` vs `send_legacy`
|
|
420
|
+
|
|
421
|
+
The same split as `events.track` / `events.record`, resolved the other way
|
|
422
|
+
round: since 1.0, `emails.send` IS the versioned send. It posts to
|
|
423
|
+
`/api/v1/emails` and answers `202` with `{id, status, to, from}`, where `status`
|
|
424
|
+
is a real delivery state you can poll on. It takes one recipient — use
|
|
425
|
+
`cc`/`bcc` to copy others — instead of fanning an array out.
|
|
426
|
+
`emails.send_legacy` is the pre-1.0 send on `POST /api/emails`, unchanged: row
|
|
427
|
+
ids, **no delivery status**, array `to` fanned out. See
|
|
428
|
+
[Upgrading from 0.x](#upgrading-from-0x).
|
|
429
|
+
|
|
430
|
+
```python
|
|
431
|
+
receipt = sendly.emails.send(
|
|
432
|
+
{"to": "user@example.com", "subject": "hi", "body": "<p>hi</p>"},
|
|
433
|
+
idempotency_key="order-42",
|
|
434
|
+
)
|
|
435
|
+
print(receipt["status"])
|
|
346
436
|
```
|
|
347
437
|
|
|
438
|
+
### Test sends
|
|
439
|
+
|
|
440
|
+
`emails.send_test` proves the send path works without touching a live
|
|
441
|
+
recipient. Two things about it are easy to get backwards:
|
|
442
|
+
|
|
443
|
+
- **The sandbox address is the *sender*, not the destination.** It is resolved
|
|
444
|
+
server-side, and naming a `from` yourself is **refused** rather than ignored —
|
|
445
|
+
so a request expecting a different sender never gets a success it would
|
|
446
|
+
misread. `projects.get()["sandbox_address"]` tells you what it sends *from*;
|
|
447
|
+
the response's `from` says the same thing.
|
|
448
|
+
- **It lands in the project owner's own inbox.** `to` is optional and defaults
|
|
449
|
+
to the project owner's verified account email, which is the only address a
|
|
450
|
+
sandbox send may reach — any other value is refused.
|
|
451
|
+
|
|
452
|
+
```python
|
|
453
|
+
test = sendly.emails.send_test({"subject": "hi", "body": "<p>hi</p>"})
|
|
454
|
+
print(test["to"], test["from"], test["sandbox"]) # sandbox is always True here
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
Everything else applies unchanged: the same rendering, the same content scan,
|
|
458
|
+
and the same daily and trust-tier caps as a real send. It takes no
|
|
459
|
+
`idempotency_key` — the recipient is the caller's own inbox, a daily cap already
|
|
460
|
+
bounds it, and "send me another one" is the normal second call rather than a
|
|
461
|
+
mistake worth deduplicating.
|
|
462
|
+
|
|
463
|
+
### What the SDK does not expose
|
|
464
|
+
|
|
465
|
+
An API key resolves no user, and a handful of routes resolve the acting project
|
|
466
|
+
admin from the session before reading any scope — so they answer `401` to any
|
|
467
|
+
key, however broad its scopes. The contract states this: those operations publish
|
|
468
|
+
`SessionAuth` without `ApiKeyAuth`.
|
|
469
|
+
|
|
470
|
+
Rather than ship methods that could never succeed, they are listed in
|
|
471
|
+
`tests/test_contract.py`'s `NOT_SDK_CALLABLE` and checked against the spec's own
|
|
472
|
+
declarations, in both directions. They are: creating and deleting a mailbox,
|
|
473
|
+
creating and revoking an app password, all four API-key operations, and creating
|
|
474
|
+
a project. Use the dashboard or an OAuth connection for those.
|
|
475
|
+
|
|
476
|
+
Mailbox **reads** are exposed — their membership check is conditional, so a key
|
|
477
|
+
really can call them.
|
|
478
|
+
|
|
348
479
|
## Error handling
|
|
349
480
|
|
|
350
481
|
Every non-2xx response raises a `SendlyError` subclass carrying `status_code`,
|
|
@@ -456,7 +587,7 @@ comparison and reject a stale or non-numeric timestamp.
|
|
|
456
587
|
|
|
457
588
|
## Async
|
|
458
589
|
|
|
459
|
-
Only a synchronous client ships
|
|
590
|
+
Only a synchronous client ships today. An `httpx.AsyncClient`-backed async
|
|
460
591
|
variant is planned.
|
|
461
592
|
|
|
462
593
|
## Development
|
|
@@ -474,6 +605,45 @@ pytest
|
|
|
474
605
|
|
|
475
606
|
Tests are fully hermetic (httpx `MockTransport`) and hit no network.
|
|
476
607
|
|
|
608
|
+
### Refreshing the vendored OpenAPI spec
|
|
609
|
+
|
|
610
|
+
`tests/fixtures/openapi.json` is a committed snapshot of Sendly's OpenAPI
|
|
611
|
+
contract; the contract suite (`tests/test_contract.py`) verifies the SDK surface
|
|
612
|
+
against it and never touches the network.
|
|
613
|
+
|
|
614
|
+
`scripts/sync_spec.py` requires `SENDLY_OPENAPI_URL`. There is **no default**,
|
|
615
|
+
and in particular it does not default to production:
|
|
616
|
+
|
|
617
|
+
```bash
|
|
618
|
+
SENDLY_OPENAPI_URL=/path/to/sendly/apps/web/openapi/openapi.json \
|
|
619
|
+
python scripts/sync_spec.py
|
|
620
|
+
|
|
621
|
+
SENDLY_OPENAPI_URL=... python scripts/sync_spec.py --check # is the copy stale?
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
`SENDLY_OPENAPI_URL` accepts a filesystem path (the normal case — the committed
|
|
625
|
+
contract in the Sendly platform monorepo at `apps/web/openapi/openapi.json`) or
|
|
626
|
+
an `http(s)://` URL of a local or staging API. Running the script with it unset
|
|
627
|
+
exits non-zero and prints what to set.
|
|
628
|
+
|
|
629
|
+
**Do not point it at `https://api.sendly.now`.** Vendoring the spec from the
|
|
630
|
+
deployed API makes the SDK mirror what is *running* rather than what the repo
|
|
631
|
+
*declares*, so any drift between the platform's code and its committed contract
|
|
632
|
+
is laundered into "correct" on the way in — the SDK re-vendors to match the
|
|
633
|
+
deployment and the mismatch vanishes silently. That destroys the vendored spec's
|
|
634
|
+
only job: it is the fixed reference `tests/test_contract.py` compares against, so
|
|
635
|
+
an SDK synced from production can no longer detect the very drift it exists to
|
|
636
|
+
catch. It is also unreproducible and unreviewable.
|
|
637
|
+
|
|
638
|
+
This is not hard-blocked — "what does production actually serve?" is a legitimate
|
|
639
|
+
one-off. Doing it prints an unmissable warning (and a CI annotation), because
|
|
640
|
+
*quiet* is what made the old default dangerous, not the host. Never commit the
|
|
641
|
+
result, and never wire that host into CI or any unattended job.
|
|
642
|
+
|
|
643
|
+
`--check` is the exception to the fail-loud rule: it never runs unattended
|
|
644
|
+
against an unknown source, so with `SENDLY_OPENAPI_URL` unset it skips with a
|
|
645
|
+
notice and exits 0, keeping CI and fork pull requests green.
|
|
646
|
+
|
|
477
647
|
## Documentation
|
|
478
648
|
|
|
479
649
|
Full API reference: <https://docs.sendly.now>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
sendly/__init__.py,sha256=
|
|
2
|
-
sendly/client.py,sha256=
|
|
1
|
+
sendly/__init__.py,sha256=BVroJYUG_CgQ3NB1fpDsUQsRP9i37q2bwYgv0jsYM2E,2755
|
|
2
|
+
sendly/client.py,sha256=zrN7isw153Adh2GPKx3CAA6AMkXhlai5p5i4uD3FO0I,11645
|
|
3
3
|
sendly/errors.py,sha256=urF33X-d39OEiasLh6C7PlrrUmmrS3XIMvFYj20rUy8,7002
|
|
4
4
|
sendly/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
-
sendly/types.py,sha256=
|
|
5
|
+
sendly/types.py,sha256=mnuTukJ6O31qCeD46EHzgATY7BQK2PX_ZMNPFGrQkP8,4065
|
|
6
6
|
sendly/webhook_utils.py,sha256=epmK860gLknaElxfXdVD3h_9ASkjWQsyl1exUC8qBL4,4762
|
|
7
7
|
sendly/resources/__init__.py,sha256=Nf7zjcFjsztXsGtrvxq3KGacX7SwDgSf3r0nAVHLvPY,73
|
|
8
8
|
sendly/resources/_helpers.py,sha256=W6AlzXuqH53Vk1dIxuQm_-Vpq9gmZvLzKPRionjH9wQ,550
|
|
@@ -10,10 +10,12 @@ sendly/resources/_pagination.py,sha256=dp0rNKruPBtCgPZT8ZPzmGGVpbFN2SCQdV4m9-qlL
|
|
|
10
10
|
sendly/resources/analytics.py,sha256=7w4A-w-dXZkgLi2j1dO6X88V-T9itlF0rWjdLiFAStc,1770
|
|
11
11
|
sendly/resources/campaigns.py,sha256=vcpwNa51ncSuieGYUhwcMLpzB-cRrf6qZ1RXB0iVmo8,5293
|
|
12
12
|
sendly/resources/contacts.py,sha256=7DwbGXvwUw8Tf5WfIUU0ZEvJs3ZBIqulkbN_Ke0-ilc,3274
|
|
13
|
-
sendly/resources/domains.py,sha256=
|
|
14
|
-
sendly/resources/emails.py,sha256=
|
|
13
|
+
sendly/resources/domains.py,sha256=2vf5hibDvL_1nCIrS17NWXFRpinTroRf4xbFMHYnx6E,3143
|
|
14
|
+
sendly/resources/emails.py,sha256=KDipwAqMJVWl52w8JjbHrLL4AFCgphOoZdG_tHrtufo,4599
|
|
15
15
|
sendly/resources/events.py,sha256=8aFUPa1iTVqEUnhwnpcsfE7_JyirtBw7POgadk4hhMs,3926
|
|
16
16
|
sendly/resources/lists.py,sha256=KXHOW_UQMEdXoat9mHJ4mJKmvrzk0AVQ3k8273GRIH4,2189
|
|
17
|
+
sendly/resources/mailboxes.py,sha256=tlXyrnsx6xpyAb3oyt2qbpK1Ri5nE4kG0AQMia4loHE,3200
|
|
18
|
+
sendly/resources/projects.py,sha256=g694LMqh7cI2qCD1nZ6QQEfOvT42A8EBltbEJawc2P8,1096
|
|
17
19
|
sendly/resources/segments.py,sha256=dc4KzorLRSfkZe63J144wm6W5bjVaDA4U8ID7HyyK_U,3944
|
|
18
20
|
sendly/resources/suppression.py,sha256=NNIhmkq1bC2qe1i4nSGZDCvuGBT7JddIrkXlVP1qASU,1716
|
|
19
21
|
sendly/resources/templates.py,sha256=DhewoFRqxGfjo0brAtG9rI6GHTCWNv4J01TylB4luXI,2104
|
|
@@ -21,7 +23,7 @@ sendly/resources/usage.py,sha256=kMcoAeGAnCilLyNihKBFcfFf-CVoSlpKUmTtlfDWAMA,879
|
|
|
21
23
|
sendly/resources/verify.py,sha256=QyVNmrzZ4Nj87YWxMPt9wlovcCyZutV9eGmn49c0EEg,818
|
|
22
24
|
sendly/resources/webhooks.py,sha256=rooES4-HMy7mhC9D1bzVDUpJ34cf1yZkVilkoWEIBEQ,2763
|
|
23
25
|
sendly/resources/workflows.py,sha256=42j-227rFUkWjA6DYmKYxqc6Bfdpxy5IHDuDgZrUO5k,5381
|
|
24
|
-
sendly_python-0.
|
|
25
|
-
sendly_python-0.
|
|
26
|
-
sendly_python-0.
|
|
27
|
-
sendly_python-0.
|
|
26
|
+
sendly_python-1.0.0.dist-info/METADATA,sha256=Nlcp2a2JuMjgQAOjCpvOLWpshDXBZdfwQEgxiwN4DfI,24681
|
|
27
|
+
sendly_python-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
28
|
+
sendly_python-1.0.0.dist-info/licenses/LICENSE,sha256=TZHT_f_vV-xujWjSy7EkTybNT6iivrfGHLd-rR1Xl7c,1073
|
|
29
|
+
sendly_python-1.0.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|