sendly-python 1.1.0__py3-none-any.whl → 1.3.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 +2 -0
- sendly/client.py +64 -25
- sendly/resources/domains.py +58 -0
- sendly/resources/mailboxes.py +116 -3
- sendly/types.py +30 -1
- {sendly_python-1.1.0.dist-info → sendly_python-1.3.0.dist-info}/METADATA +89 -5
- {sendly_python-1.1.0.dist-info → sendly_python-1.3.0.dist-info}/RECORD +9 -9
- {sendly_python-1.1.0.dist-info → sendly_python-1.3.0.dist-info}/WHEEL +0 -0
- {sendly_python-1.1.0.dist-info → sendly_python-1.3.0.dist-info}/licenses/LICENSE +0 -0
sendly/__init__.py
CHANGED
|
@@ -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",
|
sendly/client.py
CHANGED
|
@@ -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.
|
sendly/resources/domains.py
CHANGED
|
@@ -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
|
sendly/resources/mailboxes.py
CHANGED
|
@@ -7,8 +7,21 @@ from typing import TYPE_CHECKING
|
|
|
7
7
|
from sendly.resources._helpers import encode_path_segment
|
|
8
8
|
|
|
9
9
|
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
|
|
10
12
|
from sendly.client import Sendly
|
|
11
|
-
from sendly.types import
|
|
13
|
+
from sendly.types import (
|
|
14
|
+
AppPasswordList,
|
|
15
|
+
Body,
|
|
16
|
+
JSONDict,
|
|
17
|
+
MailboxConversation,
|
|
18
|
+
MailboxConversationPage,
|
|
19
|
+
MailboxDetail,
|
|
20
|
+
MailboxList,
|
|
21
|
+
MailboxMessageList,
|
|
22
|
+
Query,
|
|
23
|
+
SendlyFile,
|
|
24
|
+
)
|
|
12
25
|
|
|
13
26
|
|
|
14
27
|
class MailboxesResource:
|
|
@@ -42,8 +55,8 @@ class MailboxesResource:
|
|
|
42
55
|
project's whole allowance, and they are still returned here: a project
|
|
43
56
|
with a run of failed provisions can therefore list more than 10.
|
|
44
57
|
|
|
45
|
-
This lists the mailboxes themselves, never their contents:
|
|
46
|
-
|
|
58
|
+
This lists the mailboxes themselves, never their contents: the mail is
|
|
59
|
+
:meth:`list_conversations`, under a scope of its own.
|
|
47
60
|
"""
|
|
48
61
|
envelope = self._client.request(method="GET", path="/api/mailboxes")
|
|
49
62
|
records: MailboxList = self._client.unwrap(envelope)
|
|
@@ -131,3 +144,103 @@ class MailboxesResource:
|
|
|
131
144
|
)
|
|
132
145
|
draft: JSONDict = self._client.unwrap(envelope)
|
|
133
146
|
return draft
|
|
147
|
+
|
|
148
|
+
# The mail itself. The methods below read and answer correspondence from
|
|
149
|
+
# people outside your project, so they need ``mailbox-conversations:read``
|
|
150
|
+
# (and ``mailbox-conversations:reply`` to answer). Both scopes are opt-in
|
|
151
|
+
# only: a key holds one only if it was ticked by name when it was created,
|
|
152
|
+
# and neither the legacy full-access key nor the "Full access" preset has
|
|
153
|
+
# it. An existing key gets 403 until you mint one that does.
|
|
154
|
+
|
|
155
|
+
def list_conversations(self, id: str, query: Query | None = None) -> MailboxConversationPage:
|
|
156
|
+
"""One page of a mailbox's threads, newest activity first.
|
|
157
|
+
|
|
158
|
+
``query`` takes ``view`` (``inbox``, the default, or ``spam``),
|
|
159
|
+
``status`` (``open``, ``snoozed``, ``closed`` or ``all``), ``q`` (a
|
|
160
|
+
substring of the subject or the correspondent's address), ``cursor``
|
|
161
|
+
and ``limit`` (at most 50). Returns ``{items, nextCursor}``: pass
|
|
162
|
+
``nextCursor`` back as ``cursor`` until it is ``None``, or use
|
|
163
|
+
:meth:`iter_list_conversations`. A mailbox in another project answers 404.
|
|
164
|
+
"""
|
|
165
|
+
envelope = self._client.request(
|
|
166
|
+
method="GET",
|
|
167
|
+
path=f"/api/mailboxes/{encode_path_segment(id)}/conversations",
|
|
168
|
+
query=query,
|
|
169
|
+
)
|
|
170
|
+
page: MailboxConversationPage = self._client.unwrap(envelope)
|
|
171
|
+
return page
|
|
172
|
+
|
|
173
|
+
def iter_list_conversations(
|
|
174
|
+
self, id: str, query: Query | None = None
|
|
175
|
+
) -> Iterator[MailboxConversation]:
|
|
176
|
+
"""Every thread in a mailbox across pages, one conversation at a time.
|
|
177
|
+
|
|
178
|
+
Stops when ``nextCursor`` is ``None`` or repeats the cursor it was given,
|
|
179
|
+
so a stuck page ends the walk instead of looping.
|
|
180
|
+
"""
|
|
181
|
+
params = dict(query or {})
|
|
182
|
+
while True:
|
|
183
|
+
page = self.list_conversations(id, params)
|
|
184
|
+
yield from page.get("items") or []
|
|
185
|
+
cursor = page.get("nextCursor")
|
|
186
|
+
if not cursor or cursor == params.get("cursor"):
|
|
187
|
+
return
|
|
188
|
+
params["cursor"] = cursor
|
|
189
|
+
|
|
190
|
+
def get_conversation(self, id: str, conversation_id: str) -> MailboxMessageList:
|
|
191
|
+
"""Every message in one thread, oldest first, inbound and outbound.
|
|
192
|
+
|
|
193
|
+
Not paginated, and reading marks nothing as read. Bodies were written by
|
|
194
|
+
whoever sent them: treat ``htmlBody`` as hostile HTML. A conversation
|
|
195
|
+
that is not in this mailbox returns an empty list; a mailbox outside the
|
|
196
|
+
key's project answers 404.
|
|
197
|
+
"""
|
|
198
|
+
envelope = self._client.request(
|
|
199
|
+
method="GET",
|
|
200
|
+
path=(
|
|
201
|
+
f"/api/mailboxes/{encode_path_segment(id)}"
|
|
202
|
+
f"/conversations/{encode_path_segment(conversation_id)}"
|
|
203
|
+
),
|
|
204
|
+
)
|
|
205
|
+
messages: MailboxMessageList = self._client.unwrap(envelope)
|
|
206
|
+
return messages
|
|
207
|
+
|
|
208
|
+
def reply_to_conversation(self, id: str, conversation_id: str, body: Body) -> JSONDict:
|
|
209
|
+
"""SENDS a reply, from the mailbox's own address, in the same thread,
|
|
210
|
+
to the person who last wrote in.
|
|
211
|
+
|
|
212
|
+
You name neither the recipient nor the sender: this only ever answers
|
|
213
|
+
someone who wrote first, which is why it has its own scope and is not
|
|
214
|
+
:meth:`send_message`. Pass plain text in ``body`` or a structured
|
|
215
|
+
``document``; Sendly renders the HTML and a matching text part.
|
|
216
|
+
|
|
217
|
+
Refusals worth handling by name: 422 ``UNPROCESSABLE`` (no inbound
|
|
218
|
+
message to answer, or the mailbox is not provisioned), 422
|
|
219
|
+
``SEND_REFUSED`` (the recipient is suppressed or the project may not
|
|
220
|
+
send), 429 ``SENDING_LIMIT_REACHED``.
|
|
221
|
+
"""
|
|
222
|
+
envelope = self._client.request(
|
|
223
|
+
method="POST",
|
|
224
|
+
path=(
|
|
225
|
+
f"/api/mailboxes/{encode_path_segment(id)}"
|
|
226
|
+
f"/conversations/{encode_path_segment(conversation_id)}/reply"
|
|
227
|
+
),
|
|
228
|
+
body=body,
|
|
229
|
+
)
|
|
230
|
+
submitted: JSONDict = self._client.unwrap(envelope)
|
|
231
|
+
return submitted
|
|
232
|
+
|
|
233
|
+
def download_attachment(self, id: str, attachment_id: str) -> SendlyFile:
|
|
234
|
+
"""The bytes of one attachment, the ``url`` a message lists.
|
|
235
|
+
|
|
236
|
+
The file and its ``content_type`` were chosen by the sender: treat both
|
|
237
|
+
as untrusted, and never serve the bytes back to a browser under your own
|
|
238
|
+
origin without a fixed type.
|
|
239
|
+
"""
|
|
240
|
+
return self._client.request_bytes(
|
|
241
|
+
method="GET",
|
|
242
|
+
path=(
|
|
243
|
+
f"/api/mailboxes/{encode_path_segment(id)}"
|
|
244
|
+
f"/attachments/{encode_path_segment(attachment_id)}"
|
|
245
|
+
),
|
|
246
|
+
)
|
sendly/types.py
CHANGED
|
@@ -15,6 +15,7 @@ same across both SDKs.
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
17
|
from collections.abc import Mapping
|
|
18
|
+
from dataclasses import dataclass
|
|
18
19
|
from typing import Any
|
|
19
20
|
|
|
20
21
|
# ---------- Generic JSON ----------
|
|
@@ -38,12 +39,16 @@ Pagination = JSONDict
|
|
|
38
39
|
SendEmailData = JSONDict
|
|
39
40
|
SendEmailResponse = JSONDict
|
|
40
41
|
BatchSendResponse = JSONDict
|
|
42
|
+
#: An email row. Beyond the delivery fields it carries, ``str | None`` and ``None``
|
|
43
|
+
#: until the event happens, ``bounceType``, ``bounceSubType``, ``bounceStatusCode``,
|
|
44
|
+
#: ``bounceDiagnosticCode`` and ``complaintFeedbackType``.
|
|
41
45
|
EmailRecord = JSONDict
|
|
42
46
|
EmailListResponse = JSONDict
|
|
43
47
|
|
|
44
48
|
#: One transition in a message's delivery history -- the append-only record
|
|
45
49
|
#: behind ``status``. ``status`` says where the message is now; these say how it
|
|
46
|
-
#: got there.
|
|
50
|
+
#: got there. A bounce transition also carries ``bounceType``, ``bounceSubType``,
|
|
51
|
+
#: ``bounceStatusCode`` and ``bounceDiagnosticCode`` (each ``str | None``).
|
|
47
52
|
EmailEvent = JSONDict
|
|
48
53
|
#: An email together with its delivery history, oldest first.
|
|
49
54
|
EmailWithEvents = JSONDict
|
|
@@ -84,6 +89,26 @@ AppPasswordRecord = JSONDict
|
|
|
84
89
|
#: types here sidesteps the shadowing and keeps the annotations readable.
|
|
85
90
|
MailboxList = list[JSONDict]
|
|
86
91
|
AppPasswordList = list[JSONDict]
|
|
92
|
+
#: One page of a mailbox's threads: ``{items, nextCursor}``.
|
|
93
|
+
MailboxConversationPage = JSONDict
|
|
94
|
+
#: One thread in a mailbox: correspondent, subject, status, last activity.
|
|
95
|
+
MailboxConversation = JSONDict
|
|
96
|
+
#: A thread's messages, oldest first. Bodies are the sender's and untrusted.
|
|
97
|
+
MailboxMessageList = list[JSONDict]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class SendlyFile:
|
|
102
|
+
"""A file the API answered with, as :meth:`Sendly.request_bytes` returns it."""
|
|
103
|
+
|
|
104
|
+
#: The body, byte for byte.
|
|
105
|
+
data: bytes
|
|
106
|
+
#: As the server declared it. For a mailbox attachment that is the SENDER's
|
|
107
|
+
#: claim: do not trust it.
|
|
108
|
+
content_type: str | None
|
|
109
|
+
#: ``attachment; filename="..."`` or ``inline; ...``, verbatim.
|
|
110
|
+
content_disposition: str | None
|
|
111
|
+
|
|
87
112
|
|
|
88
113
|
# ---------- Projects (v1) ----------
|
|
89
114
|
|
|
@@ -193,6 +218,10 @@ TemplateDeletedV1 = JSONDict
|
|
|
193
218
|
DomainV1 = JSONDict
|
|
194
219
|
DomainListV1 = CursorList
|
|
195
220
|
DomainDeletedV1 = JSONDict
|
|
221
|
+
#: The logo an identity serves to inboxes, its BIMI record, and a DNS readiness reading.
|
|
222
|
+
DomainLogoV1 = JSONDict
|
|
223
|
+
#: The per-step, per-inbox checklist for showing the logo as the inbox avatar.
|
|
224
|
+
DomainLogoReadinessV1 = JSONDict
|
|
196
225
|
|
|
197
226
|
# ---------- Webhooks (v1) ----------
|
|
198
227
|
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sendly-python
|
|
3
|
-
Version: 1.
|
|
4
|
-
Summary: Official Sendly
|
|
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
5
|
Project-URL: Homepage, https://sendly.now
|
|
6
6
|
Project-URL: Documentation, https://docs.sendly.now
|
|
7
7
|
Project-URL: Repository, https://github.com/DevinoSolutions/sendly-python
|
|
8
8
|
Author-email: Devino Solutions <dev@devino.ca>
|
|
9
9
|
License: MIT
|
|
10
10
|
License-File: LICENSE
|
|
11
|
-
Keywords: api,email,sdk,sendly,transactional-email,webhooks
|
|
11
|
+
Keywords: api,email,email-api,sdk,sendly,sendly.now,transactional-email,webhooks
|
|
12
12
|
Classifier: Development Status :: 4 - Beta
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
14
14
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -32,6 +32,18 @@ Description-Content-Type: text/markdown
|
|
|
32
32
|
|
|
33
33
|
# Sendly Python SDK
|
|
34
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
|
+
|
|
35
47
|
Official Python SDK for the [Sendly](https://sendly.now) REST API —
|
|
36
48
|
transactional email, contacts, lists, topics, events, domains, templates,
|
|
37
49
|
snippets, email verification, address validation, deliverability reporting,
|
|
@@ -265,6 +277,13 @@ receipt = sendly.emails.send({"from": "a@you.com", "to": "b@them.com", "subject"
|
|
|
265
277
|
sendly.emails.send_legacy({"from": "a@you.com", "to": ["b@them.com", "c@them.com"],
|
|
266
278
|
"subject": "Hi", "body": "<p>Hi</p>"})
|
|
267
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
|
+
|
|
268
287
|
# Batch send (up to 100)
|
|
269
288
|
sendly.emails.batch({"emails": [{"from": "a@you.com", "to": "b@them.com", "subject": "Hi", "body": "<p>Hi</p>"}]})
|
|
270
289
|
|
|
@@ -292,6 +311,26 @@ for event in email["events"]:
|
|
|
292
311
|
somebody opened, read `openedAt` / `opens` on the rows — engagement is not a
|
|
293
312
|
status.
|
|
294
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
|
+
|
|
295
334
|
### Contacts
|
|
296
335
|
|
|
297
336
|
```python
|
|
@@ -464,6 +503,28 @@ neither is a spelling of the other. `verify_v1` verifies nothing itself:
|
|
|
464
503
|
verification happens in the domain's DNS when its owner publishes the DKIM
|
|
465
504
|
records SES minted at creation, and this call asks SES what it currently sees.
|
|
466
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
|
+
|
|
467
528
|
### Mailboxes
|
|
468
529
|
|
|
469
530
|
Receiving mailboxes on the project's verified domains. The reads are reads; the
|
|
@@ -484,8 +545,8 @@ for pw in sendly.mailboxes.list_app_passwords("mb_123"):
|
|
|
484
545
|
print(pw["name"], pw["lastFour"], pw["lastUsedAt"])
|
|
485
546
|
```
|
|
486
547
|
|
|
487
|
-
|
|
488
|
-
|
|
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
|
|
489
550
|
any of these reads; mailbox credentials are app passwords, created from the
|
|
490
551
|
dashboard and shown once. `list_app_passwords` returns only the passwords that
|
|
491
552
|
are still active — a revoked one drops out, so this is not an audit history.
|
|
@@ -541,6 +602,29 @@ treated strictly as data describing what to write, never as instructions to the
|
|
|
541
602
|
model. Capped at 120 requests an hour per project; 502 means the model was
|
|
542
603
|
unreachable.
|
|
543
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
|
+
|
|
544
628
|
### Templates and snippets
|
|
545
629
|
|
|
546
630
|
```python
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
sendly/__init__.py,sha256=
|
|
2
|
-
sendly/client.py,sha256=
|
|
1
|
+
sendly/__init__.py,sha256=KNGjrzKPY88Qo8J4LCWxa4Ae0Dwk1JIREQ9IFF2R8bY,3143
|
|
2
|
+
sendly/client.py,sha256=TqB84R4C3pXBinW3kg3WozKNucIGVS9t1xotgrdav9Q,13536
|
|
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=2cbZqteyutTuIxllK9Sq79_D6XjObzRSHRenRuCZ3hw,7997
|
|
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
|
|
@@ -11,11 +11,11 @@ sendly/resources/analytics.py,sha256=7w4A-w-dXZkgLi2j1dO6X88V-T9itlF0rWjdLiFAStc
|
|
|
11
11
|
sendly/resources/campaigns.py,sha256=r9XQcw9z1aCAkQic56vajh-Rscbc8jA0YjV4kIBalW4,7382
|
|
12
12
|
sendly/resources/contacts.py,sha256=JTmj5jAu46i6suz16U3cubr1P4sM0lkpHo8AcK5JDcM,7624
|
|
13
13
|
sendly/resources/deliverability.py,sha256=e69_THrIZMN7BP6i8IvTcSbeX7t3FoFZwhj3BpgW-ZE,4877
|
|
14
|
-
sendly/resources/domains.py,sha256=
|
|
14
|
+
sendly/resources/domains.py,sha256=ZRH0KmtaELJkbNTg-P-T5lJAHAgxbQe1LiMd9pqxfZM,11946
|
|
15
15
|
sendly/resources/emails.py,sha256=ukaXSBRV3kDXism4ndfIYIluoubChH4VdH65q0A9hvI,5227
|
|
16
16
|
sendly/resources/events.py,sha256=hvJpX7Q2HmUkO1mxnzFBYhoP5NfWDz2TUb2OJ1bCaQ8,4071
|
|
17
17
|
sendly/resources/lists.py,sha256=rwBLIk9Q5w_TPlDtCZ4a8yne1JQnZ0U4qa1mMq-1tGs,6044
|
|
18
|
-
sendly/resources/mailboxes.py,sha256=
|
|
18
|
+
sendly/resources/mailboxes.py,sha256=QZmwd1sADTLjoPgvb8t4t4sQeoYjGPo5aE0Tnv80Wj4,10903
|
|
19
19
|
sendly/resources/projects.py,sha256=g694LMqh7cI2qCD1nZ6QQEfOvT42A8EBltbEJawc2P8,1096
|
|
20
20
|
sendly/resources/segments.py,sha256=dc4KzorLRSfkZe63J144wm6W5bjVaDA4U8ID7HyyK_U,3944
|
|
21
21
|
sendly/resources/snippets.py,sha256=BjuMWSprTAtzF6nrqd04iC9lRc68Lf3kKMZWeNrUyMA,2645
|
|
@@ -27,7 +27,7 @@ sendly/resources/validation.py,sha256=sS0gAs5Kph-QK73YZ7dkZCUP2ZPiDtyO1DLlQuZAle
|
|
|
27
27
|
sendly/resources/verify.py,sha256=QyVNmrzZ4Nj87YWxMPt9wlovcCyZutV9eGmn49c0EEg,818
|
|
28
28
|
sendly/resources/webhooks.py,sha256=w0ZwueAGKTP6Du3b6jMr24C9nv_a_vnDExYYy3N1pfA,8036
|
|
29
29
|
sendly/resources/workflows.py,sha256=wZUpL57lrKmgcdjNaLS89lyEG2D1Ov2EKfXSlcQFn74,9768
|
|
30
|
-
sendly_python-1.
|
|
31
|
-
sendly_python-1.
|
|
32
|
-
sendly_python-1.
|
|
33
|
-
sendly_python-1.
|
|
30
|
+
sendly_python-1.3.0.dist-info/METADATA,sha256=lgummrFC5rHDlamNCa0jscQR9JokeLdnNifo9tQj4lg,60764
|
|
31
|
+
sendly_python-1.3.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
32
|
+
sendly_python-1.3.0.dist-info/licenses/LICENSE,sha256=TZHT_f_vV-xujWjSy7EkTybNT6iivrfGHLd-rR1Xl7c,1073
|
|
33
|
+
sendly_python-1.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|