sendly-python 0.1.0__py3-none-any.whl → 0.2.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 +18 -0
- sendly/client.py +47 -8
- sendly/errors.py +123 -19
- sendly/resources/_pagination.py +49 -0
- sendly/resources/analytics.py +49 -0
- sendly/resources/campaigns.py +135 -0
- sendly/resources/events.py +76 -4
- sendly/resources/lists.py +59 -0
- sendly/resources/segments.py +106 -0
- sendly/resources/usage.py +27 -0
- sendly/resources/workflows.py +139 -0
- sendly/types.py +41 -0
- {sendly_python-0.1.0.dist-info → sendly_python-0.2.0.dist-info}/METADATA +173 -1
- sendly_python-0.2.0.dist-info/RECORD +27 -0
- sendly_python-0.1.0.dist-info/RECORD +0 -20
- {sendly_python-0.1.0.dist-info → sendly_python-0.2.0.dist-info}/WHEEL +0 -0
- {sendly_python-0.1.0.dist-info → sendly_python-0.2.0.dist-info}/licenses/LICENSE +0 -0
sendly/__init__.py
CHANGED
|
@@ -6,6 +6,12 @@ Example:
|
|
|
6
6
|
>>> sendly.emails.send(
|
|
7
7
|
... {"from": "a@b.com", "to": "c@d.com", "subject": "hi", "body": "<p>hi</p>"}
|
|
8
8
|
... )
|
|
9
|
+
|
|
10
|
+
The same client also speaks the ``/api/v1`` surface — campaigns, segments,
|
|
11
|
+
workflows, analytics, usage, and the v1 event methods:
|
|
12
|
+
|
|
13
|
+
>>> for campaign in sendly.campaigns.iter_list({"limit": 100}):
|
|
14
|
+
... print(campaign["name"], campaign["status"])
|
|
9
15
|
"""
|
|
10
16
|
|
|
11
17
|
from __future__ import annotations
|
|
@@ -22,14 +28,20 @@ from sendly.errors import (
|
|
|
22
28
|
SendlyServerError,
|
|
23
29
|
SendlyValidationError,
|
|
24
30
|
)
|
|
31
|
+
from sendly.resources.analytics import AnalyticsResource
|
|
32
|
+
from sendly.resources.campaigns import CampaignsResource
|
|
25
33
|
from sendly.resources.contacts import ContactsResource
|
|
26
34
|
from sendly.resources.domains import DomainsResource
|
|
27
35
|
from sendly.resources.emails import EmailsResource
|
|
28
36
|
from sendly.resources.events import EventsResource
|
|
37
|
+
from sendly.resources.lists import ListsResource
|
|
38
|
+
from sendly.resources.segments import SegmentsResource
|
|
29
39
|
from sendly.resources.suppression import SuppressionResource
|
|
30
40
|
from sendly.resources.templates import TemplatesResource
|
|
41
|
+
from sendly.resources.usage import UsageResource
|
|
31
42
|
from sendly.resources.verify import VerifyResource
|
|
32
43
|
from sendly.resources.webhooks import WebhooksResource
|
|
44
|
+
from sendly.resources.workflows import WorkflowsResource
|
|
33
45
|
from sendly.webhook_utils import DEFAULT_TOLERANCE_MS, construct_event, verify_signature
|
|
34
46
|
|
|
35
47
|
__version__ = SDK_VERSION
|
|
@@ -38,10 +50,14 @@ __all__ = [
|
|
|
38
50
|
"DEFAULT_BASE_URL",
|
|
39
51
|
"DEFAULT_TOLERANCE_MS",
|
|
40
52
|
"SDK_VERSION",
|
|
53
|
+
"AnalyticsResource",
|
|
54
|
+
"CampaignsResource",
|
|
41
55
|
"ContactsResource",
|
|
42
56
|
"DomainsResource",
|
|
43
57
|
"EmailsResource",
|
|
44
58
|
"EventsResource",
|
|
59
|
+
"ListsResource",
|
|
60
|
+
"SegmentsResource",
|
|
45
61
|
"Sendly",
|
|
46
62
|
"SendlyAuthenticationError",
|
|
47
63
|
"SendlyConflictError",
|
|
@@ -54,8 +70,10 @@ __all__ = [
|
|
|
54
70
|
"SendlyValidationError",
|
|
55
71
|
"SuppressionResource",
|
|
56
72
|
"TemplatesResource",
|
|
73
|
+
"UsageResource",
|
|
57
74
|
"VerifyResource",
|
|
58
75
|
"WebhooksResource",
|
|
76
|
+
"WorkflowsResource",
|
|
59
77
|
"__version__",
|
|
60
78
|
"construct_event",
|
|
61
79
|
"verify_signature",
|
sendly/client.py
CHANGED
|
@@ -7,6 +7,14 @@ Ported from the reference TypeScript SDK's ``client.ts``. Behavioural parity:
|
|
|
7
7
|
* Error envelope ``{error: {code, message}}`` mapped to typed exceptions.
|
|
8
8
|
* Query params skip ``None``/empty-string; list values append repeated keys.
|
|
9
9
|
* 204 / No-Content -> ``None``; non-JSON success body -> raw text.
|
|
10
|
+
|
|
11
|
+
One client, two response dialects. The legacy ``/api/*`` resources wrap results
|
|
12
|
+
in ``{success, data}`` and report failures as ``{error: {code, message}}``. The
|
|
13
|
+
``/api/v1/*`` resources (``campaigns``, ``segments``, ``workflows``,
|
|
14
|
+
``analytics``, ``usage``, and the v1 methods on ``events``) return the resource
|
|
15
|
+
body directly — no envelope, so they never call :meth:`Sendly.unwrap` — and
|
|
16
|
+
report failures as RFC 9457 problem documents. Both dialects raise the same
|
|
17
|
+
:class:`~sendly.errors.SendlyError` subclasses.
|
|
10
18
|
"""
|
|
11
19
|
|
|
12
20
|
from __future__ import annotations
|
|
@@ -18,15 +26,27 @@ from urllib.parse import urlencode
|
|
|
18
26
|
|
|
19
27
|
import httpx
|
|
20
28
|
|
|
21
|
-
from sendly.errors import
|
|
29
|
+
from sendly.errors import (
|
|
30
|
+
SendlyConnectionError,
|
|
31
|
+
SendlyError,
|
|
32
|
+
error_from_problem,
|
|
33
|
+
error_from_response,
|
|
34
|
+
is_problem_document,
|
|
35
|
+
)
|
|
36
|
+
from sendly.resources.analytics import AnalyticsResource
|
|
37
|
+
from sendly.resources.campaigns import CampaignsResource
|
|
22
38
|
from sendly.resources.contacts import ContactsResource
|
|
23
39
|
from sendly.resources.domains import DomainsResource
|
|
24
40
|
from sendly.resources.emails import EmailsResource
|
|
25
41
|
from sendly.resources.events import EventsResource
|
|
42
|
+
from sendly.resources.lists import ListsResource
|
|
43
|
+
from sendly.resources.segments import SegmentsResource
|
|
26
44
|
from sendly.resources.suppression import SuppressionResource
|
|
27
45
|
from sendly.resources.templates import TemplatesResource
|
|
46
|
+
from sendly.resources.usage import UsageResource
|
|
28
47
|
from sendly.resources.verify import VerifyResource
|
|
29
48
|
from sendly.resources.webhooks import WebhooksResource
|
|
49
|
+
from sendly.resources.workflows import WorkflowsResource
|
|
30
50
|
|
|
31
51
|
if TYPE_CHECKING:
|
|
32
52
|
from collections.abc import Mapping
|
|
@@ -37,7 +57,7 @@ if TYPE_CHECKING:
|
|
|
37
57
|
__all__ = ["DEFAULT_BASE_URL", "SDK_VERSION", "Sendly"]
|
|
38
58
|
|
|
39
59
|
#: Package version. Kept in sync with ``pyproject.toml``.
|
|
40
|
-
SDK_VERSION = "0.
|
|
60
|
+
SDK_VERSION = "0.2.0"
|
|
41
61
|
|
|
42
62
|
#: Default production API base. Override via ``base_url`` for staging/self-hosted.
|
|
43
63
|
DEFAULT_BASE_URL = "https://api.sendly.now"
|
|
@@ -56,9 +76,11 @@ def _stringify(value: Any) -> str:
|
|
|
56
76
|
class Sendly:
|
|
57
77
|
"""Sendly SDK entry point.
|
|
58
78
|
|
|
59
|
-
Construct once with an API key and reuse the resource accessors
|
|
60
|
-
|
|
61
|
-
``verify``, ``webhooks``, ``suppression``
|
|
79
|
+
Construct once with an API key and reuse the resource accessors for all
|
|
80
|
+
calls: ``emails``, ``contacts``, ``events``, ``domains``, ``templates``,
|
|
81
|
+
``verify``, ``webhooks``, ``suppression`` and ``lists`` on the legacy
|
|
82
|
+
surface, plus ``campaigns``, ``segments``, ``workflows``, ``analytics`` and
|
|
83
|
+
``usage`` on ``/api/v1``.
|
|
62
84
|
|
|
63
85
|
Args:
|
|
64
86
|
api_key: Project API key (``sk_*`` for full access, ``pk_*`` for
|
|
@@ -112,6 +134,14 @@ class Sendly:
|
|
|
112
134
|
self.verify = VerifyResource(self)
|
|
113
135
|
self.webhooks = WebhooksResource(self)
|
|
114
136
|
self.suppression = SuppressionResource(self)
|
|
137
|
+
self.lists = ListsResource(self)
|
|
138
|
+
# /api/v1 surface. Same client, same auth; bare resource bodies instead
|
|
139
|
+
# of the legacy {success, data} envelope, and RFC 9457 problem errors.
|
|
140
|
+
self.campaigns = CampaignsResource(self)
|
|
141
|
+
self.segments = SegmentsResource(self)
|
|
142
|
+
self.workflows = WorkflowsResource(self)
|
|
143
|
+
self.analytics = AnalyticsResource(self)
|
|
144
|
+
self.usage = UsageResource(self)
|
|
115
145
|
|
|
116
146
|
def request(
|
|
117
147
|
self,
|
|
@@ -180,7 +210,9 @@ class Sendly:
|
|
|
180
210
|
return text
|
|
181
211
|
|
|
182
212
|
if not response.is_success:
|
|
183
|
-
self._raise_from_body(
|
|
213
|
+
self._raise_from_body(
|
|
214
|
+
response.status_code, parsed, response.headers.get("content-type")
|
|
215
|
+
)
|
|
184
216
|
|
|
185
217
|
return parsed
|
|
186
218
|
|
|
@@ -238,9 +270,16 @@ class Sendly:
|
|
|
238
270
|
body = json.loads(text)
|
|
239
271
|
except json.JSONDecodeError:
|
|
240
272
|
body = None
|
|
241
|
-
self._raise_from_body(response.status_code, body)
|
|
273
|
+
self._raise_from_body(response.status_code, body, response.headers.get("content-type"))
|
|
274
|
+
|
|
275
|
+
def _raise_from_body(
|
|
276
|
+
self, status_code: int, body: Any, content_type: str | None = None
|
|
277
|
+
) -> NoReturn:
|
|
278
|
+
# /api/v1 speaks RFC 9457; the legacy surface speaks {success, error}.
|
|
279
|
+
# Both land on the same exception classes, keyed off the status.
|
|
280
|
+
if is_problem_document(body, content_type):
|
|
281
|
+
raise error_from_problem(status_code, body)
|
|
242
282
|
|
|
243
|
-
def _raise_from_body(self, status_code: int, body: Any) -> NoReturn:
|
|
244
283
|
error = body.get("error") if isinstance(body, dict) else None
|
|
245
284
|
error = error if isinstance(error, dict) else {}
|
|
246
285
|
raw_message = error.get("message")
|
sendly/errors.py
CHANGED
|
@@ -3,30 +3,63 @@
|
|
|
3
3
|
Mirrors the TypeScript SDK's ``errors.ts``: a single :class:`SendlyError` base with
|
|
4
4
|
one subclass per meaningful HTTP status so callers can ``except`` a narrow type
|
|
5
5
|
without inspecting the response body.
|
|
6
|
+
|
|
7
|
+
The API speaks two error dialects and both land on the same exception classes:
|
|
8
|
+
|
|
9
|
+
* legacy ``/api/*`` — ``{success: false, error: {code, message}}``;
|
|
10
|
+
* ``/api/v1/*`` — an RFC 9457 problem document served as
|
|
11
|
+
``application/problem+json``. Its ``code`` becomes :attr:`SendlyError.error_code`
|
|
12
|
+
and its ``detail`` (falling back to ``title``) becomes the message, so
|
|
13
|
+
``except SendlyValidationError`` behaves identically across both surfaces.
|
|
14
|
+
Two problem-only fields are surfaced additively: :attr:`SendlyError.request_id`
|
|
15
|
+
and :attr:`SendlyError.field_errors`.
|
|
6
16
|
"""
|
|
7
17
|
|
|
8
18
|
from __future__ import annotations
|
|
9
19
|
|
|
10
20
|
from typing import Any
|
|
11
21
|
|
|
22
|
+
#: Media type of an RFC 9457 problem document.
|
|
23
|
+
PROBLEM_CONTENT_TYPE = "application/problem+json"
|
|
24
|
+
|
|
12
25
|
|
|
13
26
|
class SendlyError(Exception):
|
|
14
27
|
"""Base error for any non-2xx HTTP response or transport failure.
|
|
15
28
|
|
|
16
29
|
Attributes:
|
|
17
30
|
status_code: HTTP status (``0`` for client-side/transport failures).
|
|
18
|
-
error_code: Machine-readable code from the API error envelope
|
|
19
|
-
|
|
31
|
+
error_code: Machine-readable code from the API error envelope (legacy
|
|
32
|
+
``error.code`` or v1 problem ``code``), or a synthesized
|
|
33
|
+
``http_<status>`` / ``invalid_response`` / ``connection_error``.
|
|
20
34
|
message: Human-readable message.
|
|
21
|
-
body: The parsed (or raw) response body, when available.
|
|
35
|
+
body: The parsed (or raw) response body, when available. For a v1
|
|
36
|
+
failure this is the whole problem document, so ``type``, ``title``,
|
|
37
|
+
``instance`` and any other member stays reachable.
|
|
38
|
+
request_id: Correlation id from a v1 problem document (``request_id``);
|
|
39
|
+
``None`` on the legacy surface. Quote it in support requests.
|
|
40
|
+
field_errors: Field-level failures from a v1 ``validation_error``
|
|
41
|
+
problem (``errors``), each ``{pointer, code, message}``; ``None``
|
|
42
|
+
when the response carried none. The legacy surface puts its own
|
|
43
|
+
breakdown at ``body["error"]["details"]["errors"]`` instead.
|
|
22
44
|
"""
|
|
23
45
|
|
|
24
|
-
def __init__(
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
status_code: int,
|
|
49
|
+
error_code: str,
|
|
50
|
+
message: str,
|
|
51
|
+
body: Any = None,
|
|
52
|
+
*,
|
|
53
|
+
request_id: str | None = None,
|
|
54
|
+
field_errors: list[dict[str, Any]] | None = None,
|
|
55
|
+
) -> None:
|
|
25
56
|
super().__init__(message)
|
|
26
57
|
self.status_code = status_code
|
|
27
58
|
self.error_code = error_code
|
|
28
59
|
self.message = message
|
|
29
60
|
self.body = body
|
|
61
|
+
self.request_id = request_id
|
|
62
|
+
self.field_errors = field_errors
|
|
30
63
|
|
|
31
64
|
|
|
32
65
|
class SendlyValidationError(SendlyError):
|
|
@@ -69,24 +102,95 @@ class SendlyConnectionError(SendlyError):
|
|
|
69
102
|
super().__init__(0, "connection_error", message, body)
|
|
70
103
|
|
|
71
104
|
|
|
72
|
-
def
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if status_code == 400:
|
|
77
|
-
return SendlyValidationError(status_code, error_code, message, body)
|
|
105
|
+
def _error_class(status_code: int) -> type[SendlyError]:
|
|
106
|
+
"""The exception class a status maps to. Shared by both error dialects."""
|
|
107
|
+
if status_code in (400, 422):
|
|
108
|
+
return SendlyValidationError
|
|
78
109
|
if status_code == 401:
|
|
79
|
-
return SendlyAuthenticationError
|
|
110
|
+
return SendlyAuthenticationError
|
|
80
111
|
if status_code == 403:
|
|
81
|
-
return SendlyPermissionError
|
|
112
|
+
return SendlyPermissionError
|
|
82
113
|
if status_code == 404:
|
|
83
|
-
return SendlyNotFoundError
|
|
84
|
-
if status_code == 422:
|
|
85
|
-
return SendlyValidationError(status_code, error_code, message, body)
|
|
114
|
+
return SendlyNotFoundError
|
|
86
115
|
if status_code == 409:
|
|
87
|
-
return SendlyConflictError
|
|
116
|
+
return SendlyConflictError
|
|
88
117
|
if status_code == 429:
|
|
89
|
-
return SendlyRateLimitError
|
|
118
|
+
return SendlyRateLimitError
|
|
90
119
|
if status_code >= 500:
|
|
91
|
-
return SendlyServerError
|
|
92
|
-
return SendlyError
|
|
120
|
+
return SendlyServerError
|
|
121
|
+
return SendlyError
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def error_from_response(
|
|
125
|
+
status_code: int,
|
|
126
|
+
error_code: str,
|
|
127
|
+
message: str,
|
|
128
|
+
body: Any = None,
|
|
129
|
+
*,
|
|
130
|
+
request_id: str | None = None,
|
|
131
|
+
field_errors: list[dict[str, Any]] | None = None,
|
|
132
|
+
) -> SendlyError:
|
|
133
|
+
"""Map an HTTP status + error envelope to the appropriate error subclass."""
|
|
134
|
+
return _error_class(status_code)(
|
|
135
|
+
status_code,
|
|
136
|
+
error_code,
|
|
137
|
+
message,
|
|
138
|
+
body,
|
|
139
|
+
request_id=request_id,
|
|
140
|
+
field_errors=field_errors,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def is_problem_document(body: Any, content_type: str | None = None) -> bool:
|
|
145
|
+
"""Is this response body an RFC 9457 problem document?
|
|
146
|
+
|
|
147
|
+
Trusts the ``application/problem+json`` content type when present, and
|
|
148
|
+
otherwise falls back to the document shape (``type`` + ``title`` + ``code``),
|
|
149
|
+
so a proxy that rewrites the media type cannot downgrade a v1 error into the
|
|
150
|
+
generic ``http_<status>`` path. The legacy ``{success, error}`` envelope
|
|
151
|
+
carries none of those members, so it can never match.
|
|
152
|
+
"""
|
|
153
|
+
if not isinstance(body, dict):
|
|
154
|
+
return False
|
|
155
|
+
if content_type and PROBLEM_CONTENT_TYPE in content_type.lower():
|
|
156
|
+
return True
|
|
157
|
+
return all(isinstance(body.get(key), str) for key in ("type", "title", "code"))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def error_from_problem(status_code: int, problem: dict[str, Any]) -> SendlyError:
|
|
161
|
+
"""Map an RFC 9457 problem document to the exception class for its status.
|
|
162
|
+
|
|
163
|
+
``code`` supplies the machine-readable :attr:`SendlyError.error_code` and
|
|
164
|
+
``detail`` the message, falling back to ``title`` — a problem document always
|
|
165
|
+
carries a title but only sometimes an occurrence-specific detail.
|
|
166
|
+
"""
|
|
167
|
+
raw_code = problem.get("code")
|
|
168
|
+
code = raw_code if isinstance(raw_code, str) and raw_code else f"http_{status_code}"
|
|
169
|
+
|
|
170
|
+
message = ""
|
|
171
|
+
for key in ("detail", "title"):
|
|
172
|
+
value = problem.get(key)
|
|
173
|
+
if isinstance(value, str) and value:
|
|
174
|
+
message = value
|
|
175
|
+
break
|
|
176
|
+
if not message:
|
|
177
|
+
message = f"Sendly request failed with status {status_code}"
|
|
178
|
+
|
|
179
|
+
raw_request_id = problem.get("request_id")
|
|
180
|
+
request_id = raw_request_id if isinstance(raw_request_id, str) else None
|
|
181
|
+
|
|
182
|
+
raw_errors = problem.get("errors")
|
|
183
|
+
field_errors = (
|
|
184
|
+
[item for item in raw_errors if isinstance(item, dict)]
|
|
185
|
+
if isinstance(raw_errors, list)
|
|
186
|
+
else None
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return error_from_response(
|
|
190
|
+
status_code,
|
|
191
|
+
code,
|
|
192
|
+
message,
|
|
193
|
+
problem,
|
|
194
|
+
request_id=request_id,
|
|
195
|
+
field_errors=field_errors,
|
|
196
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Auto-pagination over the ``/api/v1`` cursor envelope.
|
|
2
|
+
|
|
3
|
+
Every v1 list endpoint answers with ``{data, has_more, next_cursor}``: an opaque
|
|
4
|
+
forward-only cursor and no total (deliberately — counting a project's rows is a
|
|
5
|
+
scan the API refuses to pay for on every page). :func:`iterate_cursor` walks that
|
|
6
|
+
shape so callers can treat a multi-page listing as one stream of items.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Callable, Iterator
|
|
15
|
+
|
|
16
|
+
from sendly.types import JSONDict, Query
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def iterate_cursor(
|
|
20
|
+
fetch: Callable[[dict[str, Any]], Any],
|
|
21
|
+
query: Query | None = None,
|
|
22
|
+
) -> Iterator[JSONDict]:
|
|
23
|
+
"""Yield every item across the pages ``fetch`` returns.
|
|
24
|
+
|
|
25
|
+
``fetch`` receives the query for one page — the caller's own filters, plus
|
|
26
|
+
the ``after`` cursor from page two onward — and returns the raw cursor
|
|
27
|
+
envelope. Iteration stops when ``has_more`` is false or ``next_cursor`` is
|
|
28
|
+
``None``, and also when a page carries no ``data`` list, so a malformed
|
|
29
|
+
response ends the walk instead of looping forever.
|
|
30
|
+
|
|
31
|
+
The caller's filters are held fixed for the whole walk on purpose: changing
|
|
32
|
+
them mid-pagination invalidates the cursor and the API answers ``422
|
|
33
|
+
validation_error`` telling you to restart from the first page.
|
|
34
|
+
"""
|
|
35
|
+
params = dict(query or {})
|
|
36
|
+
while True:
|
|
37
|
+
page = fetch(params)
|
|
38
|
+
if not isinstance(page, dict):
|
|
39
|
+
return
|
|
40
|
+
items = page.get("data")
|
|
41
|
+
if not isinstance(items, list):
|
|
42
|
+
return
|
|
43
|
+
yield from items
|
|
44
|
+
if not page.get("has_more"):
|
|
45
|
+
return
|
|
46
|
+
cursor = page.get("next_cursor")
|
|
47
|
+
if not cursor:
|
|
48
|
+
return
|
|
49
|
+
params = {**params, "after": cursor}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Analytics 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 (
|
|
10
|
+
AnalyticsTimeseries,
|
|
11
|
+
CampaignAnalytics,
|
|
12
|
+
Query,
|
|
13
|
+
TopCampaignList,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AnalyticsResource:
|
|
18
|
+
"""Aggregate sending and engagement metrics.
|
|
19
|
+
|
|
20
|
+
Every method takes an optional ``from`` / ``to`` window (ISO 8601) and echoes
|
|
21
|
+
the resolved window back as ``window``, so a caller can tell what the API
|
|
22
|
+
actually measured when it defaulted the range. None of these are
|
|
23
|
+
cursor-paginated — they answer a bounded aggregate, not a listing — so there
|
|
24
|
+
are no iterators here.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, client: Sendly) -> None:
|
|
28
|
+
self._client = client
|
|
29
|
+
|
|
30
|
+
def timeseries(self, query: Query | None = None) -> AnalyticsTimeseries:
|
|
31
|
+
"""Per-day sending and engagement counts over the window."""
|
|
32
|
+
response: AnalyticsTimeseries = self._client.request(
|
|
33
|
+
method="GET", path="/api/v1/analytics/timeseries", query=query
|
|
34
|
+
)
|
|
35
|
+
return response
|
|
36
|
+
|
|
37
|
+
def campaigns(self, query: Query | None = None) -> CampaignAnalytics:
|
|
38
|
+
"""Campaign totals for the window: counts plus average open/click rates."""
|
|
39
|
+
response: CampaignAnalytics = self._client.request(
|
|
40
|
+
method="GET", path="/api/v1/analytics/campaigns", query=query
|
|
41
|
+
)
|
|
42
|
+
return response
|
|
43
|
+
|
|
44
|
+
def top_campaigns(self, query: Query | None = None) -> TopCampaignList:
|
|
45
|
+
"""Best-performing campaigns in the window. Accepts ``limit``."""
|
|
46
|
+
response: TopCampaignList = self._client.request(
|
|
47
|
+
method="GET", path="/api/v1/analytics/top-campaigns", query=query
|
|
48
|
+
)
|
|
49
|
+
return response
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Campaigns resource (``/api/v1``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from sendly.resources._helpers import encode_path_segment, idempotency_headers
|
|
8
|
+
from sendly.resources._pagination import iterate_cursor
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
|
|
13
|
+
from sendly.client import Sendly
|
|
14
|
+
from sendly.types import (
|
|
15
|
+
Body,
|
|
16
|
+
CampaignDeleted,
|
|
17
|
+
CampaignList,
|
|
18
|
+
CampaignRecord,
|
|
19
|
+
CampaignStats,
|
|
20
|
+
JSONDict,
|
|
21
|
+
Query,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CampaignsResource:
|
|
26
|
+
"""Create, schedule, and run bulk email campaigns.
|
|
27
|
+
|
|
28
|
+
A campaign moves through ``DRAFT`` -> ``SENDING`` -> ``SENT``; :meth:`send`
|
|
29
|
+
starts it (optionally at a future time), and :meth:`pause` / :meth:`resume` /
|
|
30
|
+
:meth:`cancel` steer it while in flight. Responses are bare v1 resource
|
|
31
|
+
bodies — there is no ``{success, data}`` envelope to unwrap.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, client: Sendly) -> None:
|
|
35
|
+
self._client = client
|
|
36
|
+
|
|
37
|
+
def list(self, query: Query | None = None) -> CampaignList:
|
|
38
|
+
"""List campaigns, newest first.
|
|
39
|
+
|
|
40
|
+
Accepts ``limit`` (1-100, default 20) and ``after`` (opaque cursor), and
|
|
41
|
+
answers ``{data, has_more, next_cursor}``. Keep the filters identical for
|
|
42
|
+
every page of one walk — changing them invalidates the cursor and the API
|
|
43
|
+
answers 422 ``validation_error`` telling you to restart from the first
|
|
44
|
+
page. :meth:`iter_list` does that bookkeeping for you.
|
|
45
|
+
"""
|
|
46
|
+
response: CampaignList = self._client.request(
|
|
47
|
+
method="GET", path="/api/v1/campaigns", query=query
|
|
48
|
+
)
|
|
49
|
+
return response
|
|
50
|
+
|
|
51
|
+
def iter_list(self, query: Query | None = None) -> Iterator[JSONDict]:
|
|
52
|
+
"""Iterate every campaign across pages, following the cursor for you."""
|
|
53
|
+
return iterate_cursor(self.list, query)
|
|
54
|
+
|
|
55
|
+
def create(self, body: Body, *, idempotency_key: str | None = None) -> CampaignRecord:
|
|
56
|
+
"""Create a campaign in ``DRAFT``.
|
|
57
|
+
|
|
58
|
+
Requires ``name``, ``subject``, ``body``, ``from`` and ``audience_type``.
|
|
59
|
+
Pass ``idempotency_key`` (1-255 chars) to make a replayed create return
|
|
60
|
+
the original campaign instead of a second one.
|
|
61
|
+
"""
|
|
62
|
+
response: CampaignRecord = self._client.request(
|
|
63
|
+
method="POST",
|
|
64
|
+
path="/api/v1/campaigns",
|
|
65
|
+
body=body,
|
|
66
|
+
headers=idempotency_headers(idempotency_key),
|
|
67
|
+
)
|
|
68
|
+
return response
|
|
69
|
+
|
|
70
|
+
def get(self, id: str) -> CampaignRecord:
|
|
71
|
+
"""Fetch a single campaign, including its delivery ``stats``."""
|
|
72
|
+
response: CampaignRecord = self._client.request(
|
|
73
|
+
method="GET", path=f"/api/v1/campaigns/{encode_path_segment(id)}"
|
|
74
|
+
)
|
|
75
|
+
return response
|
|
76
|
+
|
|
77
|
+
def update(self, id: str, body: Body) -> CampaignRecord:
|
|
78
|
+
"""Patch a draft campaign's content or audience."""
|
|
79
|
+
response: CampaignRecord = self._client.request(
|
|
80
|
+
method="PATCH", path=f"/api/v1/campaigns/{encode_path_segment(id)}", body=body
|
|
81
|
+
)
|
|
82
|
+
return response
|
|
83
|
+
|
|
84
|
+
def delete(self, id: str) -> CampaignDeleted:
|
|
85
|
+
"""Delete a campaign. Returns the ``{id, deleted}`` confirmation body."""
|
|
86
|
+
response: CampaignDeleted = self._client.request(
|
|
87
|
+
method="DELETE", path=f"/api/v1/campaigns/{encode_path_segment(id)}"
|
|
88
|
+
)
|
|
89
|
+
return response
|
|
90
|
+
|
|
91
|
+
def send(
|
|
92
|
+
self, id: str, body: Body | None = None, *, idempotency_key: str | None = None
|
|
93
|
+
) -> CampaignRecord:
|
|
94
|
+
"""Send a campaign now, or schedule it.
|
|
95
|
+
|
|
96
|
+
Pass ``{"scheduled_for": "<ISO 8601>"}`` as ``body`` to queue it for a
|
|
97
|
+
future time instead of sending immediately. ``idempotency_key`` is the
|
|
98
|
+
guard that matters most on this call: a replayed send must not mail the
|
|
99
|
+
audience twice.
|
|
100
|
+
"""
|
|
101
|
+
response: CampaignRecord = self._client.request(
|
|
102
|
+
method="POST",
|
|
103
|
+
path=f"/api/v1/campaigns/{encode_path_segment(id)}/send",
|
|
104
|
+
body=body,
|
|
105
|
+
headers=idempotency_headers(idempotency_key),
|
|
106
|
+
)
|
|
107
|
+
return response
|
|
108
|
+
|
|
109
|
+
def cancel(self, id: str) -> CampaignRecord:
|
|
110
|
+
"""Cancel a scheduled or in-flight campaign. Already-sent mail stays sent."""
|
|
111
|
+
response: CampaignRecord = self._client.request(
|
|
112
|
+
method="POST", path=f"/api/v1/campaigns/{encode_path_segment(id)}/cancel"
|
|
113
|
+
)
|
|
114
|
+
return response
|
|
115
|
+
|
|
116
|
+
def pause(self, id: str) -> CampaignRecord:
|
|
117
|
+
"""Pause an in-flight campaign, holding the remaining recipients."""
|
|
118
|
+
response: CampaignRecord = self._client.request(
|
|
119
|
+
method="POST", path=f"/api/v1/campaigns/{encode_path_segment(id)}/pause"
|
|
120
|
+
)
|
|
121
|
+
return response
|
|
122
|
+
|
|
123
|
+
def resume(self, id: str) -> CampaignRecord:
|
|
124
|
+
"""Resume a paused campaign from where it stopped."""
|
|
125
|
+
response: CampaignRecord = self._client.request(
|
|
126
|
+
method="POST", path=f"/api/v1/campaigns/{encode_path_segment(id)}/resume"
|
|
127
|
+
)
|
|
128
|
+
return response
|
|
129
|
+
|
|
130
|
+
def stats(self, id: str) -> CampaignStats:
|
|
131
|
+
"""Delivery and engagement counters plus derived rates for one campaign."""
|
|
132
|
+
response: CampaignStats = self._client.request(
|
|
133
|
+
method="GET", path=f"/api/v1/campaigns/{encode_path_segment(id)}/stats"
|
|
134
|
+
)
|
|
135
|
+
return response
|
sendly/resources/events.py
CHANGED
|
@@ -1,22 +1,43 @@
|
|
|
1
|
-
"""Events resource."""
|
|
1
|
+
"""Events resource (legacy ``/api/track`` + the ``/api/v1/events`` surface)."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from typing import TYPE_CHECKING
|
|
6
6
|
|
|
7
|
+
from sendly.resources._pagination import iterate_cursor
|
|
8
|
+
|
|
7
9
|
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
|
|
8
12
|
from sendly.client import Sendly
|
|
9
|
-
from sendly.types import
|
|
13
|
+
from sendly.types import (
|
|
14
|
+
Body,
|
|
15
|
+
EventList,
|
|
16
|
+
EventNameList,
|
|
17
|
+
EventRecord,
|
|
18
|
+
EventStats,
|
|
19
|
+
JSONDict,
|
|
20
|
+
Query,
|
|
21
|
+
TrackEventData,
|
|
22
|
+
)
|
|
10
23
|
|
|
11
24
|
|
|
12
25
|
class EventsResource:
|
|
13
|
-
"""Record custom events for contacts.
|
|
26
|
+
"""Record custom events for contacts, and query the ones already recorded.
|
|
27
|
+
|
|
28
|
+
Two write methods, one per API surface. :meth:`track` is the original
|
|
29
|
+
``POST /api/track`` call and is unchanged; :meth:`record` is its ``/api/v1``
|
|
30
|
+
counterpart. They do the same thing — the difference is the dialect: v1
|
|
31
|
+
returns the event body directly and reports failures as RFC 9457 problem
|
|
32
|
+
documents. New code should prefer :meth:`record`, alongside the v1 read
|
|
33
|
+
methods below.
|
|
34
|
+
"""
|
|
14
35
|
|
|
15
36
|
def __init__(self, client: Sendly) -> None:
|
|
16
37
|
self._client = client
|
|
17
38
|
|
|
18
39
|
def track(self, body: Body) -> TrackEventData:
|
|
19
|
-
"""Record a custom event for a contact.
|
|
40
|
+
"""Record a custom event for a contact (legacy ``/api/track``).
|
|
20
41
|
|
|
21
42
|
Both full (``sk_*``) and sending-only (``pk_*``) keys are accepted.
|
|
22
43
|
Reserved system event names (e.g. ``email.sent``) are rejected by the API.
|
|
@@ -24,3 +45,54 @@ class EventsResource:
|
|
|
24
45
|
envelope = self._client.request(method="POST", path="/api/track", body=body)
|
|
25
46
|
data: TrackEventData = self._client.unwrap(envelope)
|
|
26
47
|
return data
|
|
48
|
+
|
|
49
|
+
def record(self, body: Body) -> EventRecord:
|
|
50
|
+
"""Record a custom event for a contact (``/api/v1``).
|
|
51
|
+
|
|
52
|
+
Requires ``name``; optionally takes ``contact_id`` and a ``data`` object.
|
|
53
|
+
The v1 counterpart of :meth:`track`, returning the created event body
|
|
54
|
+
rather than a ``{success, data}`` envelope.
|
|
55
|
+
|
|
56
|
+
Takes no ``Idempotency-Key``: events are the highest-volume write on the
|
|
57
|
+
surface and append-only by nature, so the API deliberately does not
|
|
58
|
+
ledger them. If a duplicate would matter to you, dedupe on your side.
|
|
59
|
+
"""
|
|
60
|
+
response: EventRecord = self._client.request(
|
|
61
|
+
method="POST", path="/api/v1/events", body=body
|
|
62
|
+
)
|
|
63
|
+
return response
|
|
64
|
+
|
|
65
|
+
def list(self, query: Query | None = None) -> EventList:
|
|
66
|
+
"""List recorded events, newest first.
|
|
67
|
+
|
|
68
|
+
Accepts ``limit`` (1-100, default 20), ``after`` (opaque cursor), and
|
|
69
|
+
``event_name`` to filter to one event. Answers
|
|
70
|
+
``{data, has_more, next_cursor}`` — no total, deliberately. Keep the
|
|
71
|
+
filters identical across one walk: changing them invalidates the cursor
|
|
72
|
+
and the API answers 422 ``validation_error`` telling you to restart from
|
|
73
|
+
the first page.
|
|
74
|
+
"""
|
|
75
|
+
response: EventList = self._client.request(method="GET", path="/api/v1/events", query=query)
|
|
76
|
+
return response
|
|
77
|
+
|
|
78
|
+
def iter_list(self, query: Query | None = None) -> Iterator[JSONDict]:
|
|
79
|
+
"""Iterate every matching event across pages, following the cursor for you."""
|
|
80
|
+
return iterate_cursor(self.list, query)
|
|
81
|
+
|
|
82
|
+
def list_names(self) -> EventNameList:
|
|
83
|
+
"""The distinct event names recorded on the project.
|
|
84
|
+
|
|
85
|
+
Takes no arguments — the endpoint declares no parameters, and the answer
|
|
86
|
+
is the project's whole name set. Useful for building a workflow trigger:
|
|
87
|
+
a workflow's ``event_name`` has to match a name events are actually
|
|
88
|
+
recorded under.
|
|
89
|
+
"""
|
|
90
|
+
response: EventNameList = self._client.request(method="GET", path="/api/v1/events/names")
|
|
91
|
+
return response
|
|
92
|
+
|
|
93
|
+
def stats(self, query: Query | None = None) -> EventStats:
|
|
94
|
+
"""Per-event counts over an optional ``from`` / ``to`` window."""
|
|
95
|
+
response: EventStats = self._client.request(
|
|
96
|
+
method="GET", path="/api/v1/events/stats", query=query
|
|
97
|
+
)
|
|
98
|
+
return response
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Lists resource (subscribe / unsubscribe)."""
|
|
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 Body, ListSubscribeData, ListUnsubscribeData
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ListsResource:
|
|
15
|
+
"""Manage a contact's membership on a subscriber list.
|
|
16
|
+
|
|
17
|
+
Both calls accept sending-only (``pk_*``) keys so they can back a public
|
|
18
|
+
subscribe or preference form directly.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, client: Sendly) -> None:
|
|
22
|
+
self._client = client
|
|
23
|
+
|
|
24
|
+
def subscribe(self, id: str, body: Body) -> ListSubscribeData:
|
|
25
|
+
"""Subscribe an address to a list, creating the contact if needed.
|
|
26
|
+
|
|
27
|
+
Requires ``email``. Two behaviours worth knowing before you wire this to
|
|
28
|
+
a form:
|
|
29
|
+
|
|
30
|
+
* When the list has double opt-in, the membership is created ``PENDING``
|
|
31
|
+
and the response carries a ``confirmToken``. Sendly does **not** send
|
|
32
|
+
the confirmation email — deliver ``/api/lists/confirm?token=<token>``
|
|
33
|
+
to the contact yourself.
|
|
34
|
+
* Re-subscribing an address that previously opted out fails with
|
|
35
|
+
``409 RESUBSCRIBE_CONFIRMATION_REQUIRED`` unless the body sets
|
|
36
|
+
``allowResubscribe: true``. Read ``previousStatus`` rather than
|
|
37
|
+
``created`` to describe the transition back to the user.
|
|
38
|
+
"""
|
|
39
|
+
envelope = self._client.request(
|
|
40
|
+
method="POST",
|
|
41
|
+
path=f"/api/lists/{encode_path_segment(id)}/subscribe",
|
|
42
|
+
body=body,
|
|
43
|
+
)
|
|
44
|
+
data: ListSubscribeData = self._client.unwrap(envelope)
|
|
45
|
+
return data
|
|
46
|
+
|
|
47
|
+
def unsubscribe(self, id: str, body: Body) -> ListUnsubscribeData:
|
|
48
|
+
"""Mark an address's membership on this list ``UNSUBSCRIBED``.
|
|
49
|
+
|
|
50
|
+
Requires ``email``. Idempotent — unsubscribing an address that is not a
|
|
51
|
+
member succeeds.
|
|
52
|
+
"""
|
|
53
|
+
envelope = self._client.request(
|
|
54
|
+
method="POST",
|
|
55
|
+
path=f"/api/lists/{encode_path_segment(id)}/unsubscribe",
|
|
56
|
+
body=body,
|
|
57
|
+
)
|
|
58
|
+
data: ListUnsubscribeData = self._client.unwrap(envelope)
|
|
59
|
+
return data
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Segments resource (``/api/v1``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from sendly.resources._helpers import encode_path_segment
|
|
8
|
+
from sendly.resources._pagination import iterate_cursor
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
|
|
13
|
+
from sendly.client import Sendly
|
|
14
|
+
from sendly.types import (
|
|
15
|
+
Body,
|
|
16
|
+
ContactList,
|
|
17
|
+
JSONDict,
|
|
18
|
+
Query,
|
|
19
|
+
SegmentDeleted,
|
|
20
|
+
SegmentList,
|
|
21
|
+
SegmentRecord,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SegmentsResource:
|
|
26
|
+
"""Group contacts into static lists or dynamic, condition-driven audiences.
|
|
27
|
+
|
|
28
|
+
A ``DYNAMIC`` segment's ``condition`` is evaluated by the API — its
|
|
29
|
+
``member_count`` is computed at creation and kept current — so an invalid
|
|
30
|
+
condition fails the create with a 422 rather than silently matching nothing.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, client: Sendly) -> None:
|
|
34
|
+
self._client = client
|
|
35
|
+
|
|
36
|
+
def list(self, query: Query | None = None) -> SegmentList:
|
|
37
|
+
"""List segments.
|
|
38
|
+
|
|
39
|
+
Accepts ``limit`` (1-100, default 20) and ``after`` (opaque cursor), and
|
|
40
|
+
answers ``{data, has_more, next_cursor}``. Hold the filters steady across
|
|
41
|
+
one walk — changing them mid-pagination invalidates the cursor and the
|
|
42
|
+
API answers 422 ``validation_error`` telling you to restart from the
|
|
43
|
+
first page.
|
|
44
|
+
"""
|
|
45
|
+
response: SegmentList = self._client.request(
|
|
46
|
+
method="GET", path="/api/v1/segments", query=query
|
|
47
|
+
)
|
|
48
|
+
return response
|
|
49
|
+
|
|
50
|
+
def iter_list(self, query: Query | None = None) -> Iterator[JSONDict]:
|
|
51
|
+
"""Iterate every segment across pages, following the cursor for you."""
|
|
52
|
+
return iterate_cursor(self.list, query)
|
|
53
|
+
|
|
54
|
+
def create(self, body: Body) -> SegmentRecord:
|
|
55
|
+
"""Create a segment. Requires ``name``.
|
|
56
|
+
|
|
57
|
+
Takes no ``Idempotency-Key``: creating a segment neither sends anything
|
|
58
|
+
nor consumes quota, so a duplicate costs one row that a
|
|
59
|
+
:meth:`delete` undoes.
|
|
60
|
+
"""
|
|
61
|
+
response: SegmentRecord = self._client.request(
|
|
62
|
+
method="POST", path="/api/v1/segments", body=body
|
|
63
|
+
)
|
|
64
|
+
return response
|
|
65
|
+
|
|
66
|
+
def get(self, id: str) -> SegmentRecord:
|
|
67
|
+
"""Fetch a single segment, including its current ``member_count``."""
|
|
68
|
+
response: SegmentRecord = self._client.request(
|
|
69
|
+
method="GET", path=f"/api/v1/segments/{encode_path_segment(id)}"
|
|
70
|
+
)
|
|
71
|
+
return response
|
|
72
|
+
|
|
73
|
+
def update(self, id: str, body: Body) -> SegmentRecord:
|
|
74
|
+
"""Patch a segment's name, description, condition, or membership tracking."""
|
|
75
|
+
response: SegmentRecord = self._client.request(
|
|
76
|
+
method="PATCH", path=f"/api/v1/segments/{encode_path_segment(id)}", body=body
|
|
77
|
+
)
|
|
78
|
+
return response
|
|
79
|
+
|
|
80
|
+
def delete(self, id: str) -> SegmentDeleted:
|
|
81
|
+
"""Delete a segment. Returns the ``{id, deleted}`` confirmation body.
|
|
82
|
+
|
|
83
|
+
Removes the grouping, not the contacts in it.
|
|
84
|
+
"""
|
|
85
|
+
response: SegmentDeleted = self._client.request(
|
|
86
|
+
method="DELETE", path=f"/api/v1/segments/{encode_path_segment(id)}"
|
|
87
|
+
)
|
|
88
|
+
return response
|
|
89
|
+
|
|
90
|
+
def list_contacts(self, id: str, query: Query | None = None) -> ContactList:
|
|
91
|
+
"""List the contacts currently in a segment.
|
|
92
|
+
|
|
93
|
+
Cursor-paginated like :meth:`list` (``limit`` / ``after``). For a dynamic
|
|
94
|
+
segment this is evaluated against the live condition, so membership can
|
|
95
|
+
differ between two walks.
|
|
96
|
+
"""
|
|
97
|
+
response: ContactList = self._client.request(
|
|
98
|
+
method="GET",
|
|
99
|
+
path=f"/api/v1/segments/{encode_path_segment(id)}/contacts",
|
|
100
|
+
query=query,
|
|
101
|
+
)
|
|
102
|
+
return response
|
|
103
|
+
|
|
104
|
+
def iter_list_contacts(self, id: str, query: Query | None = None) -> Iterator[JSONDict]:
|
|
105
|
+
"""Iterate every contact in a segment across pages."""
|
|
106
|
+
return iterate_cursor(lambda params: self.list_contacts(id, params), query)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Usage 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 UsageSummary
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UsageResource:
|
|
13
|
+
"""The caller's plan and its current quota consumption."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, client: Sendly) -> None:
|
|
16
|
+
self._client = client
|
|
17
|
+
|
|
18
|
+
def get(self) -> UsageSummary:
|
|
19
|
+
"""Current ``plan`` plus ``monthly`` and ``daily`` usage against its limits.
|
|
20
|
+
|
|
21
|
+
Read this before a large send to see the headroom the API would enforce:
|
|
22
|
+
exceeding a quota answers 429 ``quota_exhausted``, which is a different
|
|
23
|
+
failure from 429 ``rate_limited`` (too fast, retry) and is not fixed by
|
|
24
|
+
backing off.
|
|
25
|
+
"""
|
|
26
|
+
response: UsageSummary = self._client.request(method="GET", path="/api/v1/usage")
|
|
27
|
+
return response
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Workflows resource (``/api/v1``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from sendly.resources._helpers import encode_path_segment
|
|
8
|
+
from sendly.resources._pagination import iterate_cursor
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
|
|
13
|
+
from sendly.client import Sendly
|
|
14
|
+
from sendly.types import (
|
|
15
|
+
Body,
|
|
16
|
+
JSONDict,
|
|
17
|
+
Query,
|
|
18
|
+
WorkflowDeleted,
|
|
19
|
+
WorkflowExecutionList,
|
|
20
|
+
WorkflowExecutionRecord,
|
|
21
|
+
WorkflowList,
|
|
22
|
+
WorkflowRecord,
|
|
23
|
+
WorkflowStats,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WorkflowsResource:
|
|
28
|
+
"""Event-triggered automations and their per-contact executions.
|
|
29
|
+
|
|
30
|
+
A workflow fires when its ``event_name`` arrives for a contact (see
|
|
31
|
+
:meth:`~sendly.resources.events.EventsResource.record`); ``allow_reentry``
|
|
32
|
+
decides whether a contact already running the workflow can start it again.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, client: Sendly) -> None:
|
|
36
|
+
self._client = client
|
|
37
|
+
|
|
38
|
+
def list(self, query: Query | None = None) -> WorkflowList:
|
|
39
|
+
"""List workflows.
|
|
40
|
+
|
|
41
|
+
Accepts ``limit`` (1-100, default 20) and ``after`` (opaque cursor), and
|
|
42
|
+
answers ``{data, has_more, next_cursor}``. Keep the filters identical for
|
|
43
|
+
every page of one walk — changing them invalidates the cursor and the API
|
|
44
|
+
answers 422 ``validation_error`` telling you to restart from the first
|
|
45
|
+
page.
|
|
46
|
+
"""
|
|
47
|
+
response: WorkflowList = self._client.request(
|
|
48
|
+
method="GET", path="/api/v1/workflows", query=query
|
|
49
|
+
)
|
|
50
|
+
return response
|
|
51
|
+
|
|
52
|
+
def iter_list(self, query: Query | None = None) -> Iterator[JSONDict]:
|
|
53
|
+
"""Iterate every workflow across pages, following the cursor for you."""
|
|
54
|
+
return iterate_cursor(self.list, query)
|
|
55
|
+
|
|
56
|
+
def create(self, body: Body) -> WorkflowRecord:
|
|
57
|
+
"""Create a workflow. Requires ``name`` and ``event_name``."""
|
|
58
|
+
response: WorkflowRecord = self._client.request(
|
|
59
|
+
method="POST", path="/api/v1/workflows", body=body
|
|
60
|
+
)
|
|
61
|
+
return response
|
|
62
|
+
|
|
63
|
+
def get(self, id: str) -> WorkflowRecord:
|
|
64
|
+
"""Fetch a single workflow."""
|
|
65
|
+
response: WorkflowRecord = self._client.request(
|
|
66
|
+
method="GET", path=f"/api/v1/workflows/{encode_path_segment(id)}"
|
|
67
|
+
)
|
|
68
|
+
return response
|
|
69
|
+
|
|
70
|
+
def update(self, id: str, body: Body) -> WorkflowRecord:
|
|
71
|
+
"""Patch a workflow — including ``enabled``, which is how you pause one."""
|
|
72
|
+
response: WorkflowRecord = self._client.request(
|
|
73
|
+
method="PATCH", path=f"/api/v1/workflows/{encode_path_segment(id)}", body=body
|
|
74
|
+
)
|
|
75
|
+
return response
|
|
76
|
+
|
|
77
|
+
def delete(self, id: str) -> WorkflowDeleted:
|
|
78
|
+
"""Delete a workflow. Returns the ``{id, deleted}`` confirmation body."""
|
|
79
|
+
response: WorkflowDeleted = self._client.request(
|
|
80
|
+
method="DELETE", path=f"/api/v1/workflows/{encode_path_segment(id)}"
|
|
81
|
+
)
|
|
82
|
+
return response
|
|
83
|
+
|
|
84
|
+
def list_executions(self, id: str, query: Query | None = None) -> WorkflowExecutionList:
|
|
85
|
+
"""List one workflow's executions.
|
|
86
|
+
|
|
87
|
+
Cursor-paginated (``limit`` / ``after``) and filterable by ``status``.
|
|
88
|
+
As with every v1 listing, a filter you change mid-walk invalidates the
|
|
89
|
+
cursor — restart from the first page instead.
|
|
90
|
+
"""
|
|
91
|
+
response: WorkflowExecutionList = self._client.request(
|
|
92
|
+
method="GET",
|
|
93
|
+
path=f"/api/v1/workflows/{encode_path_segment(id)}/executions",
|
|
94
|
+
query=query,
|
|
95
|
+
)
|
|
96
|
+
return response
|
|
97
|
+
|
|
98
|
+
def iter_list_executions(self, id: str, query: Query | None = None) -> Iterator[JSONDict]:
|
|
99
|
+
"""Iterate every execution of a workflow across pages."""
|
|
100
|
+
return iterate_cursor(lambda params: self.list_executions(id, params), query)
|
|
101
|
+
|
|
102
|
+
def start_execution(self, id: str, body: Body) -> WorkflowExecutionRecord:
|
|
103
|
+
"""Start the workflow for one contact, bypassing its event trigger.
|
|
104
|
+
|
|
105
|
+
``body`` is required and must carry ``contact_id`` — an execution always
|
|
106
|
+
belongs to a contact. It may also carry a ``context`` object the
|
|
107
|
+
workflow's steps can read.
|
|
108
|
+
"""
|
|
109
|
+
response: WorkflowExecutionRecord = self._client.request(
|
|
110
|
+
method="POST",
|
|
111
|
+
path=f"/api/v1/workflows/{encode_path_segment(id)}/executions",
|
|
112
|
+
body=body,
|
|
113
|
+
)
|
|
114
|
+
return response
|
|
115
|
+
|
|
116
|
+
def cancel_execution(self, execution_id: str) -> WorkflowExecutionRecord:
|
|
117
|
+
"""Cancel one in-flight execution.
|
|
118
|
+
|
|
119
|
+
Addressed by execution id alone — the route is
|
|
120
|
+
``/api/v1/workflows/executions/{execution_id}/cancel``, not nested under
|
|
121
|
+
the workflow — so a caller holding an execution id needs nothing else.
|
|
122
|
+
"""
|
|
123
|
+
response: WorkflowExecutionRecord = self._client.request(
|
|
124
|
+
method="POST",
|
|
125
|
+
path=f"/api/v1/workflows/executions/{encode_path_segment(execution_id)}/cancel",
|
|
126
|
+
)
|
|
127
|
+
return response
|
|
128
|
+
|
|
129
|
+
def stats(self, id: str, query: Query | None = None) -> WorkflowStats:
|
|
130
|
+
"""Execution totals, completion rate, and attributed email/conversion counts.
|
|
131
|
+
|
|
132
|
+
Accepts ``from`` to bound the window.
|
|
133
|
+
"""
|
|
134
|
+
response: WorkflowStats = self._client.request(
|
|
135
|
+
method="GET",
|
|
136
|
+
path=f"/api/v1/workflows/{encode_path_segment(id)}/stats",
|
|
137
|
+
query=query,
|
|
138
|
+
)
|
|
139
|
+
return response
|
sendly/types.py
CHANGED
|
@@ -73,6 +73,11 @@ SuppressionRecord = JSONDict
|
|
|
73
73
|
SuppressionListResponse = JSONDict
|
|
74
74
|
SuppressionCheckResponse = JSONDict
|
|
75
75
|
|
|
76
|
+
# ---------- Lists ----------
|
|
77
|
+
|
|
78
|
+
ListSubscribeData = JSONDict
|
|
79
|
+
ListUnsubscribeData = JSONDict
|
|
80
|
+
|
|
76
81
|
# ---------- Events ----------
|
|
77
82
|
|
|
78
83
|
TrackEventData = JSONDict
|
|
@@ -82,3 +87,39 @@ TrackEventResponse = JSONDict
|
|
|
82
87
|
|
|
83
88
|
VerifyEmailData = JSONDict
|
|
84
89
|
VerifyEmailResponse = JSONDict
|
|
90
|
+
|
|
91
|
+
# ---------- /api/v1 ----------
|
|
92
|
+
#
|
|
93
|
+
# The v1 surface returns bare resource bodies (no {success, data} envelope) and
|
|
94
|
+
# a uniform list envelope: {data, has_more, next_cursor}. The ``*List`` aliases
|
|
95
|
+
# below name that envelope; the iterator methods yield the items inside ``data``.
|
|
96
|
+
|
|
97
|
+
CursorList = JSONDict
|
|
98
|
+
|
|
99
|
+
CampaignRecord = JSONDict
|
|
100
|
+
CampaignList = CursorList
|
|
101
|
+
CampaignStats = JSONDict
|
|
102
|
+
CampaignDeleted = JSONDict
|
|
103
|
+
|
|
104
|
+
SegmentRecord = JSONDict
|
|
105
|
+
SegmentList = CursorList
|
|
106
|
+
SegmentDeleted = JSONDict
|
|
107
|
+
ContactList = CursorList
|
|
108
|
+
|
|
109
|
+
WorkflowRecord = JSONDict
|
|
110
|
+
WorkflowList = CursorList
|
|
111
|
+
WorkflowDeleted = JSONDict
|
|
112
|
+
WorkflowStats = JSONDict
|
|
113
|
+
WorkflowExecutionRecord = JSONDict
|
|
114
|
+
WorkflowExecutionList = CursorList
|
|
115
|
+
|
|
116
|
+
EventRecord = JSONDict
|
|
117
|
+
EventList = CursorList
|
|
118
|
+
EventNameList = JSONDict
|
|
119
|
+
EventStats = JSONDict
|
|
120
|
+
|
|
121
|
+
AnalyticsTimeseries = JSONDict
|
|
122
|
+
CampaignAnalytics = JSONDict
|
|
123
|
+
TopCampaignList = JSONDict
|
|
124
|
+
|
|
125
|
+
UsageSummary = JSONDict
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sendly-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -62,6 +62,25 @@ pip install git+https://github.com/DevinoSolutions/sendly-python.git
|
|
|
62
62
|
|
|
63
63
|
Requires Python 3.10+.
|
|
64
64
|
|
|
65
|
+
## Already on Resend, SendGrid, Postmark, Mailgun, or Plunk?
|
|
66
|
+
|
|
67
|
+
You don't even need this SDK to try Sendly. The API also speaks the
|
|
68
|
+
transactional-send dialect of those providers — keep the vendor SDK you already
|
|
69
|
+
run and change **two things**: the base URL and the API key.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import resend # your existing Resend integration
|
|
73
|
+
|
|
74
|
+
resend.api_key = "sk_your_sendly_key"
|
|
75
|
+
resend.api_url = "https://api.sendly.now/api/compat/resend"
|
|
76
|
+
# resend.Emails.send(...) now sends through Sendly — same code, same shapes.
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Every compat request runs through the same pipeline as the native API (domain
|
|
80
|
+
verification, suppression, limits), and anything a dialect can express that
|
|
81
|
+
Sendly doesn't support returns a clean error in that vendor's own error shape.
|
|
82
|
+
Per-provider guides: [docs.sendly.now/migrate](https://docs.sendly.now/migrate).
|
|
83
|
+
|
|
65
84
|
## Quickstart
|
|
66
85
|
|
|
67
86
|
The client reads your API key from the `SENDLY_API_KEY` environment variable:
|
|
@@ -207,6 +226,125 @@ sendly.suppression.get("bounce@example.com")
|
|
|
207
226
|
sendly.suppression.remove("bounce@example.com")
|
|
208
227
|
```
|
|
209
228
|
|
|
229
|
+
### Lists
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
# Both calls accept sending-only (pk_*) keys, so they can back a public form.
|
|
233
|
+
result = sendly.lists.subscribe("l_123", {"email": "user@example.com"})
|
|
234
|
+
|
|
235
|
+
# On a double opt-in list the membership is PENDING and carries a confirmToken.
|
|
236
|
+
# Sendly does NOT send the confirmation email — deliver this link yourself.
|
|
237
|
+
if result["status"] == "PENDING":
|
|
238
|
+
confirm_url = f"https://api.sendly.now/api/lists/confirm?token={result['confirmToken']}"
|
|
239
|
+
|
|
240
|
+
# Re-subscribing an address that opted out needs an explicit opt-in, or the call
|
|
241
|
+
# fails with 409 RESUBSCRIBE_CONFIRMATION_REQUIRED.
|
|
242
|
+
sendly.lists.subscribe("l_123", {"email": "user@example.com", "allowResubscribe": True})
|
|
243
|
+
|
|
244
|
+
sendly.lists.unsubscribe("l_123", {"email": "user@example.com"})
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## The v1 API
|
|
248
|
+
|
|
249
|
+
`campaigns`, `segments`, `workflows`, `analytics` and `usage` — plus the v1
|
|
250
|
+
methods on `events` — speak Sendly's `/api/v1` surface. Same client, same API
|
|
251
|
+
key; two differences worth knowing:
|
|
252
|
+
|
|
253
|
+
- **Responses are bare resource bodies.** There is no `{success, data}` envelope
|
|
254
|
+
to unwrap, so what the API documents is exactly what you get.
|
|
255
|
+
- **Errors are RFC 9457 problem documents.** They raise the same exception
|
|
256
|
+
classes as the legacy surface, with two extra fields — see
|
|
257
|
+
[Error handling](#error-handling).
|
|
258
|
+
|
|
259
|
+
### Campaigns
|
|
260
|
+
|
|
261
|
+
```python
|
|
262
|
+
campaign = sendly.campaigns.create(
|
|
263
|
+
{
|
|
264
|
+
"name": "August launch",
|
|
265
|
+
"subject": "We are live",
|
|
266
|
+
"body": "<p>Hello</p>",
|
|
267
|
+
"from": "team@you.com",
|
|
268
|
+
"audience_type": "ALL",
|
|
269
|
+
},
|
|
270
|
+
idempotency_key="august-launch",
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# Send now, or schedule it. Key the replay — a duplicate send mails the audience twice.
|
|
274
|
+
sendly.campaigns.send(campaign["id"], idempotency_key="august-launch-send")
|
|
275
|
+
sendly.campaigns.send(campaign["id"], {"scheduled_for": "2026-09-01T10:00:00Z"})
|
|
276
|
+
|
|
277
|
+
sendly.campaigns.pause(campaign["id"])
|
|
278
|
+
sendly.campaigns.resume(campaign["id"])
|
|
279
|
+
sendly.campaigns.cancel(campaign["id"])
|
|
280
|
+
|
|
281
|
+
stats = sendly.campaigns.stats(campaign["id"])
|
|
282
|
+
print(stats["delivered"], stats["open_rate"])
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Pagination
|
|
286
|
+
|
|
287
|
+
Every v1 list answers `{data, has_more, next_cursor}` — an opaque forward-only
|
|
288
|
+
cursor, and no total. Page it yourself with `limit` (1–100, default 20) and
|
|
289
|
+
`after`:
|
|
290
|
+
|
|
291
|
+
```python
|
|
292
|
+
page = sendly.campaigns.list({"limit": 50})
|
|
293
|
+
while page["has_more"]:
|
|
294
|
+
page = sendly.campaigns.list({"limit": 50, "after": page["next_cursor"]})
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
…or let the `iter_*` companion do it. It yields individual items and follows the
|
|
298
|
+
cursor until the last page:
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
for campaign in sendly.campaigns.iter_list({"limit": 100}):
|
|
302
|
+
print(campaign["name"], campaign["status"])
|
|
303
|
+
|
|
304
|
+
for contact in sendly.segments.iter_list_contacts("seg_123"):
|
|
305
|
+
print(contact["email"])
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Keep your filters identical for every page of one walk. Changing them
|
|
309
|
+
mid-pagination invalidates the cursor and the API answers `422 validation_error`
|
|
310
|
+
telling you to restart from the first page — which is exactly why `iter_*` holds
|
|
311
|
+
the query fixed and only advances `after`.
|
|
312
|
+
|
|
313
|
+
Available on the six cursor-paginated listings: `campaigns.iter_list`,
|
|
314
|
+
`segments.iter_list`, `segments.iter_list_contacts`, `workflows.iter_list`,
|
|
315
|
+
`workflows.iter_list_executions`, `events.iter_list`. The analytics endpoints and
|
|
316
|
+
`events.list_names` / `events.stats` return a bounded aggregate rather than a
|
|
317
|
+
cursor, so they have no iterator.
|
|
318
|
+
|
|
319
|
+
### Segments, workflows, events, analytics, usage
|
|
320
|
+
|
|
321
|
+
```python
|
|
322
|
+
segment = sendly.segments.create({"name": "Power users", "type": "DYNAMIC",
|
|
323
|
+
"condition": {"field": "plan", "op": "eq", "value": "pro"}})
|
|
324
|
+
sendly.segments.list_contacts(segment["id"], {"limit": 50})
|
|
325
|
+
|
|
326
|
+
workflow = sendly.workflows.create({"name": "Welcome", "event_name": "signup.completed"})
|
|
327
|
+
sendly.workflows.start_execution(workflow["id"], {"contact_id": "c_123"})
|
|
328
|
+
# Executions are cancelled by execution id alone — not nested under the workflow.
|
|
329
|
+
sendly.workflows.cancel_execution("exe_123")
|
|
330
|
+
sendly.workflows.stats(workflow["id"], {"from": "2026-08-01"})
|
|
331
|
+
|
|
332
|
+
# events.record is the v1 counterpart of the legacy events.track. Same effect,
|
|
333
|
+
# v1 dialect. It takes no idempotency_key: events are append-only and the API
|
|
334
|
+
# deliberately does not ledger them.
|
|
335
|
+
sendly.events.record({"name": "signup.completed", "contact_id": "c_123", "data": {"plan": "pro"}})
|
|
336
|
+
sendly.events.list({"event_name": "signup.completed", "limit": 20})
|
|
337
|
+
sendly.events.list_names()
|
|
338
|
+
sendly.events.stats({"from": "2026-08-01", "to": "2026-08-31"})
|
|
339
|
+
|
|
340
|
+
sendly.analytics.timeseries({"from": "2026-08-01", "to": "2026-08-31"})
|
|
341
|
+
sendly.analytics.campaigns()
|
|
342
|
+
sendly.analytics.top_campaigns({"limit": 5})
|
|
343
|
+
|
|
344
|
+
usage = sendly.usage.get()
|
|
345
|
+
print(usage["plan"], usage["monthly"])
|
|
346
|
+
```
|
|
347
|
+
|
|
210
348
|
## Error handling
|
|
211
349
|
|
|
212
350
|
Every non-2xx response raises a `SendlyError` subclass carrying `status_code`,
|
|
@@ -244,6 +382,40 @@ Invalid input raises `SendlyValidationError`. Migrated routes report it as HTTP
|
|
|
244
382
|
`err.body["error"]["details"]["errors"]`; legacy/malformed requests still use
|
|
245
383
|
`400`. Both surface as `SendlyValidationError`.
|
|
246
384
|
|
|
385
|
+
### v1 errors (RFC 9457)
|
|
386
|
+
|
|
387
|
+
The `/api/v1` surface reports failures as `application/problem+json` documents.
|
|
388
|
+
They raise the **same** exception classes, keyed off the same statuses, so
|
|
389
|
+
existing `except` blocks keep working. Three things move:
|
|
390
|
+
|
|
391
|
+
- `error_code` comes from the problem's `code` — a lowercase, machine-readable
|
|
392
|
+
value like `scope_missing`, `quota_exhausted`, or `idempotency_key_reused`.
|
|
393
|
+
- `err.request_id` carries the correlation id. Quote it in support requests.
|
|
394
|
+
- `err.field_errors` carries the per-field breakdown on a `validation_error`,
|
|
395
|
+
each entry `{pointer, code, message}` with an RFC 6901 JSON Pointer.
|
|
396
|
+
|
|
397
|
+
```python
|
|
398
|
+
from sendly import Sendly, SendlyValidationError, SendlyRateLimitError
|
|
399
|
+
|
|
400
|
+
sendly = Sendly()
|
|
401
|
+
try:
|
|
402
|
+
sendly.campaigns.create({"name": "Launch"})
|
|
403
|
+
except SendlyValidationError as err:
|
|
404
|
+
print(err.error_code, err.message, err.request_id)
|
|
405
|
+
for field in err.field_errors or []:
|
|
406
|
+
print(f" {field['pointer']}: {field['message']}")
|
|
407
|
+
except SendlyRateLimitError as err:
|
|
408
|
+
# Two different failures share this class — check the code before retrying.
|
|
409
|
+
if err.error_code == "quota_exhausted":
|
|
410
|
+
print("Plan limit reached; backing off will not help")
|
|
411
|
+
else:
|
|
412
|
+
print("Too fast — retry with backoff")
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
The full problem document stays on `err.body`, so `type`, `title` and `instance`
|
|
416
|
+
remain reachable. On the legacy surface `request_id` and `field_errors` are
|
|
417
|
+
`None`.
|
|
418
|
+
|
|
247
419
|
## Verifying webhooks
|
|
248
420
|
|
|
249
421
|
Every delivery is signed. Verify it against the **raw** request body — do not
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
sendly/__init__.py,sha256=AQ1Yu-7DFJ9LQz_-w6RSk_u25U-oITJGwRwJ1M9v-9s,2487
|
|
2
|
+
sendly/client.py,sha256=GRII4hPJwBNPDPL9i84y3YwtPvRgT5CVXGkBgL1r7IY,11356
|
|
3
|
+
sendly/errors.py,sha256=urF33X-d39OEiasLh6C7PlrrUmmrS3XIMvFYj20rUy8,7002
|
|
4
|
+
sendly/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
sendly/types.py,sha256=ggxBcjjkdgmJEPNk04tFdrLoTggOFZuj2kcqsvBPIXk,3140
|
|
6
|
+
sendly/webhook_utils.py,sha256=epmK860gLknaElxfXdVD3h_9ASkjWQsyl1exUC8qBL4,4762
|
|
7
|
+
sendly/resources/__init__.py,sha256=Nf7zjcFjsztXsGtrvxq3KGacX7SwDgSf3r0nAVHLvPY,73
|
|
8
|
+
sendly/resources/_helpers.py,sha256=W6AlzXuqH53Vk1dIxuQm_-Vpq9gmZvLzKPRionjH9wQ,550
|
|
9
|
+
sendly/resources/_pagination.py,sha256=dp0rNKruPBtCgPZT8ZPzmGGVpbFN2SCQdV4m9-qlLEA,1786
|
|
10
|
+
sendly/resources/analytics.py,sha256=7w4A-w-dXZkgLi2j1dO6X88V-T9itlF0rWjdLiFAStc,1770
|
|
11
|
+
sendly/resources/campaigns.py,sha256=vcpwNa51ncSuieGYUhwcMLpzB-cRrf6qZ1RXB0iVmo8,5293
|
|
12
|
+
sendly/resources/contacts.py,sha256=7DwbGXvwUw8Tf5WfIUU0ZEvJs3ZBIqulkbN_Ke0-ilc,3274
|
|
13
|
+
sendly/resources/domains.py,sha256=m69uHKGhmtVlzo3QAmMmMHvmpc0T3JAv5izTWxDpxhc,2368
|
|
14
|
+
sendly/resources/emails.py,sha256=3LHxre7lJfa_6z1F9Rzmdh5QDwhYpwTWaAJZGVCLsY8,2390
|
|
15
|
+
sendly/resources/events.py,sha256=8aFUPa1iTVqEUnhwnpcsfE7_JyirtBw7POgadk4hhMs,3926
|
|
16
|
+
sendly/resources/lists.py,sha256=KXHOW_UQMEdXoat9mHJ4mJKmvrzk0AVQ3k8273GRIH4,2189
|
|
17
|
+
sendly/resources/segments.py,sha256=dc4KzorLRSfkZe63J144wm6W5bjVaDA4U8ID7HyyK_U,3944
|
|
18
|
+
sendly/resources/suppression.py,sha256=NNIhmkq1bC2qe1i4nSGZDCvuGBT7JddIrkXlVP1qASU,1716
|
|
19
|
+
sendly/resources/templates.py,sha256=DhewoFRqxGfjo0brAtG9rI6GHTCWNv4J01TylB4luXI,2104
|
|
20
|
+
sendly/resources/usage.py,sha256=kMcoAeGAnCilLyNihKBFcfFf-CVoSlpKUmTtlfDWAMA,879
|
|
21
|
+
sendly/resources/verify.py,sha256=QyVNmrzZ4Nj87YWxMPt9wlovcCyZutV9eGmn49c0EEg,818
|
|
22
|
+
sendly/resources/webhooks.py,sha256=rooES4-HMy7mhC9D1bzVDUpJ34cf1yZkVilkoWEIBEQ,2763
|
|
23
|
+
sendly/resources/workflows.py,sha256=42j-227rFUkWjA6DYmKYxqc6Bfdpxy5IHDuDgZrUO5k,5381
|
|
24
|
+
sendly_python-0.2.0.dist-info/METADATA,sha256=qP01SKUw9EeFuqAMVvnnyPTMLrgs-nwZZJlU9lbAn2w,16456
|
|
25
|
+
sendly_python-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
26
|
+
sendly_python-0.2.0.dist-info/licenses/LICENSE,sha256=TZHT_f_vV-xujWjSy7EkTybNT6iivrfGHLd-rR1Xl7c,1073
|
|
27
|
+
sendly_python-0.2.0.dist-info/RECORD,,
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
sendly/__init__.py,sha256=EK_NplzmfmE_d_CcW7epJdKbZLVFn5zK3culailQLkA,1764
|
|
2
|
-
sendly/client.py,sha256=fiiRtDeV3Wt1nLA2ERsK-rlSNg9XBzj4wNgiUA-kYY0,9467
|
|
3
|
-
sendly/errors.py,sha256=7nlE2ixqRXS21zYe5qwb-rTUyv1Wx3yI9uThsf4sJoQ,3394
|
|
4
|
-
sendly/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
-
sendly/types.py,sha256=YiHQaggce5KIEr5cZMg0Ks923mDX9kNUrpWLujvf00k,2159
|
|
6
|
-
sendly/webhook_utils.py,sha256=epmK860gLknaElxfXdVD3h_9ASkjWQsyl1exUC8qBL4,4762
|
|
7
|
-
sendly/resources/__init__.py,sha256=Nf7zjcFjsztXsGtrvxq3KGacX7SwDgSf3r0nAVHLvPY,73
|
|
8
|
-
sendly/resources/_helpers.py,sha256=W6AlzXuqH53Vk1dIxuQm_-Vpq9gmZvLzKPRionjH9wQ,550
|
|
9
|
-
sendly/resources/contacts.py,sha256=7DwbGXvwUw8Tf5WfIUU0ZEvJs3ZBIqulkbN_Ke0-ilc,3274
|
|
10
|
-
sendly/resources/domains.py,sha256=m69uHKGhmtVlzo3QAmMmMHvmpc0T3JAv5izTWxDpxhc,2368
|
|
11
|
-
sendly/resources/emails.py,sha256=3LHxre7lJfa_6z1F9Rzmdh5QDwhYpwTWaAJZGVCLsY8,2390
|
|
12
|
-
sendly/resources/events.py,sha256=hEy1HCRWfPS_Mc7xxWZtgqj9R8iYsgPRR1YdZuGuP1c,785
|
|
13
|
-
sendly/resources/suppression.py,sha256=NNIhmkq1bC2qe1i4nSGZDCvuGBT7JddIrkXlVP1qASU,1716
|
|
14
|
-
sendly/resources/templates.py,sha256=DhewoFRqxGfjo0brAtG9rI6GHTCWNv4J01TylB4luXI,2104
|
|
15
|
-
sendly/resources/verify.py,sha256=QyVNmrzZ4Nj87YWxMPt9wlovcCyZutV9eGmn49c0EEg,818
|
|
16
|
-
sendly/resources/webhooks.py,sha256=rooES4-HMy7mhC9D1bzVDUpJ34cf1yZkVilkoWEIBEQ,2763
|
|
17
|
-
sendly_python-0.1.0.dist-info/METADATA,sha256=_CFNo8me4v-Duk1M_PWpiiq_FKQNcfd3ASOh4jSN-IM,9560
|
|
18
|
-
sendly_python-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
19
|
-
sendly_python-0.1.0.dist-info/licenses/LICENSE,sha256=TZHT_f_vV-xujWjSy7EkTybNT6iivrfGHLd-rR1Xl7c,1073
|
|
20
|
-
sendly_python-0.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|