sendly-python 0.1.0__py3-none-any.whl → 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sendly/__init__.py +25 -1
- sendly/client.py +52 -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/domains.py +16 -0
- sendly/resources/emails.py +50 -3
- sendly/resources/events.py +76 -4
- sendly/resources/lists.py +59 -0
- sendly/resources/mailboxes.py +75 -0
- sendly/resources/projects.py +33 -0
- sendly/resources/segments.py +106 -0
- sendly/resources/usage.py +27 -0
- sendly/resources/workflows.py +139 -0
- sendly/types.py +65 -0
- sendly_python-1.0.0.dist-info/METADATA +653 -0
- sendly_python-1.0.0.dist-info/RECORD +29 -0
- sendly_python-0.1.0.dist-info/METADATA +0 -311
- sendly_python-0.1.0.dist-info/RECORD +0 -20
- {sendly_python-0.1.0.dist-info → sendly_python-1.0.0.dist-info}/WHEEL +0 -0
- {sendly_python-0.1.0.dist-info → sendly_python-1.0.0.dist-info}/licenses/LICENSE +0 -0
sendly/__init__.py
CHANGED
|
@@ -3,9 +3,17 @@
|
|
|
3
3
|
Example:
|
|
4
4
|
>>> from sendly import Sendly
|
|
5
5
|
>>> sendly = Sendly() # reads SENDLY_API_KEY
|
|
6
|
-
>>> sendly.emails.send(
|
|
6
|
+
>>> receipt = sendly.emails.send(
|
|
7
7
|
... {"from": "a@b.com", "to": "c@d.com", "subject": "hi", "body": "<p>hi</p>"}
|
|
8
8
|
... )
|
|
9
|
+
>>> receipt["status"] # a real delivery state; poll emails.get(receipt["id"])
|
|
10
|
+
'PENDING'
|
|
11
|
+
|
|
12
|
+
The same client also speaks the ``/api/v1`` surface — campaigns, segments,
|
|
13
|
+
workflows, analytics, usage, and the v1 event methods:
|
|
14
|
+
|
|
15
|
+
>>> for campaign in sendly.campaigns.iter_list({"limit": 100}):
|
|
16
|
+
... print(campaign["name"], campaign["status"])
|
|
9
17
|
"""
|
|
10
18
|
|
|
11
19
|
from __future__ import annotations
|
|
@@ -22,14 +30,22 @@ from sendly.errors import (
|
|
|
22
30
|
SendlyServerError,
|
|
23
31
|
SendlyValidationError,
|
|
24
32
|
)
|
|
33
|
+
from sendly.resources.analytics import AnalyticsResource
|
|
34
|
+
from sendly.resources.campaigns import CampaignsResource
|
|
25
35
|
from sendly.resources.contacts import ContactsResource
|
|
26
36
|
from sendly.resources.domains import DomainsResource
|
|
27
37
|
from sendly.resources.emails import EmailsResource
|
|
28
38
|
from sendly.resources.events import EventsResource
|
|
39
|
+
from sendly.resources.lists import ListsResource
|
|
40
|
+
from sendly.resources.mailboxes import MailboxesResource
|
|
41
|
+
from sendly.resources.projects import ProjectsResource
|
|
42
|
+
from sendly.resources.segments import SegmentsResource
|
|
29
43
|
from sendly.resources.suppression import SuppressionResource
|
|
30
44
|
from sendly.resources.templates import TemplatesResource
|
|
45
|
+
from sendly.resources.usage import UsageResource
|
|
31
46
|
from sendly.resources.verify import VerifyResource
|
|
32
47
|
from sendly.resources.webhooks import WebhooksResource
|
|
48
|
+
from sendly.resources.workflows import WorkflowsResource
|
|
33
49
|
from sendly.webhook_utils import DEFAULT_TOLERANCE_MS, construct_event, verify_signature
|
|
34
50
|
|
|
35
51
|
__version__ = SDK_VERSION
|
|
@@ -38,10 +54,16 @@ __all__ = [
|
|
|
38
54
|
"DEFAULT_BASE_URL",
|
|
39
55
|
"DEFAULT_TOLERANCE_MS",
|
|
40
56
|
"SDK_VERSION",
|
|
57
|
+
"AnalyticsResource",
|
|
58
|
+
"CampaignsResource",
|
|
41
59
|
"ContactsResource",
|
|
42
60
|
"DomainsResource",
|
|
43
61
|
"EmailsResource",
|
|
44
62
|
"EventsResource",
|
|
63
|
+
"ListsResource",
|
|
64
|
+
"MailboxesResource",
|
|
65
|
+
"ProjectsResource",
|
|
66
|
+
"SegmentsResource",
|
|
45
67
|
"Sendly",
|
|
46
68
|
"SendlyAuthenticationError",
|
|
47
69
|
"SendlyConflictError",
|
|
@@ -54,8 +76,10 @@ __all__ = [
|
|
|
54
76
|
"SendlyValidationError",
|
|
55
77
|
"SuppressionResource",
|
|
56
78
|
"TemplatesResource",
|
|
79
|
+
"UsageResource",
|
|
57
80
|
"VerifyResource",
|
|
58
81
|
"WebhooksResource",
|
|
82
|
+
"WorkflowsResource",
|
|
59
83
|
"__version__",
|
|
60
84
|
"construct_event",
|
|
61
85
|
"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,29 @@ 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.mailboxes import MailboxesResource
|
|
44
|
+
from sendly.resources.projects import ProjectsResource
|
|
45
|
+
from sendly.resources.segments import SegmentsResource
|
|
26
46
|
from sendly.resources.suppression import SuppressionResource
|
|
27
47
|
from sendly.resources.templates import TemplatesResource
|
|
48
|
+
from sendly.resources.usage import UsageResource
|
|
28
49
|
from sendly.resources.verify import VerifyResource
|
|
29
50
|
from sendly.resources.webhooks import WebhooksResource
|
|
51
|
+
from sendly.resources.workflows import WorkflowsResource
|
|
30
52
|
|
|
31
53
|
if TYPE_CHECKING:
|
|
32
54
|
from collections.abc import Mapping
|
|
@@ -37,7 +59,7 @@ if TYPE_CHECKING:
|
|
|
37
59
|
__all__ = ["DEFAULT_BASE_URL", "SDK_VERSION", "Sendly"]
|
|
38
60
|
|
|
39
61
|
#: Package version. Kept in sync with ``pyproject.toml``.
|
|
40
|
-
SDK_VERSION = "
|
|
62
|
+
SDK_VERSION = "1.0.0"
|
|
41
63
|
|
|
42
64
|
#: Default production API base. Override via ``base_url`` for staging/self-hosted.
|
|
43
65
|
DEFAULT_BASE_URL = "https://api.sendly.now"
|
|
@@ -56,9 +78,11 @@ def _stringify(value: Any) -> str:
|
|
|
56
78
|
class Sendly:
|
|
57
79
|
"""Sendly SDK entry point.
|
|
58
80
|
|
|
59
|
-
Construct once with an API key and reuse the resource accessors
|
|
60
|
-
|
|
61
|
-
``verify``, ``webhooks``, ``suppression``
|
|
81
|
+
Construct once with an API key and reuse the resource accessors for all
|
|
82
|
+
calls: ``emails``, ``contacts``, ``events``, ``domains``, ``templates``,
|
|
83
|
+
``verify``, ``webhooks``, ``suppression`` and ``lists`` on the legacy
|
|
84
|
+
surface, plus ``campaigns``, ``segments``, ``workflows``, ``analytics`` and
|
|
85
|
+
``usage`` on ``/api/v1``.
|
|
62
86
|
|
|
63
87
|
Args:
|
|
64
88
|
api_key: Project API key (``sk_*`` for full access, ``pk_*`` for
|
|
@@ -112,6 +136,17 @@ class Sendly:
|
|
|
112
136
|
self.verify = VerifyResource(self)
|
|
113
137
|
self.webhooks = WebhooksResource(self)
|
|
114
138
|
self.suppression = SuppressionResource(self)
|
|
139
|
+
self.lists = ListsResource(self)
|
|
140
|
+
# Reads only -- the mailbox writes need a user, which an API key is not.
|
|
141
|
+
self.mailboxes = MailboxesResource(self)
|
|
142
|
+
# /api/v1 surface. Same client, same auth; bare resource bodies instead
|
|
143
|
+
# of the legacy {success, data} envelope, and RFC 9457 problem errors.
|
|
144
|
+
self.campaigns = CampaignsResource(self)
|
|
145
|
+
self.segments = SegmentsResource(self)
|
|
146
|
+
self.workflows = WorkflowsResource(self)
|
|
147
|
+
self.analytics = AnalyticsResource(self)
|
|
148
|
+
self.usage = UsageResource(self)
|
|
149
|
+
self.projects = ProjectsResource(self)
|
|
115
150
|
|
|
116
151
|
def request(
|
|
117
152
|
self,
|
|
@@ -180,7 +215,9 @@ class Sendly:
|
|
|
180
215
|
return text
|
|
181
216
|
|
|
182
217
|
if not response.is_success:
|
|
183
|
-
self._raise_from_body(
|
|
218
|
+
self._raise_from_body(
|
|
219
|
+
response.status_code, parsed, response.headers.get("content-type")
|
|
220
|
+
)
|
|
184
221
|
|
|
185
222
|
return parsed
|
|
186
223
|
|
|
@@ -238,9 +275,16 @@ class Sendly:
|
|
|
238
275
|
body = json.loads(text)
|
|
239
276
|
except json.JSONDecodeError:
|
|
240
277
|
body = None
|
|
241
|
-
self._raise_from_body(response.status_code, body)
|
|
278
|
+
self._raise_from_body(response.status_code, body, response.headers.get("content-type"))
|
|
279
|
+
|
|
280
|
+
def _raise_from_body(
|
|
281
|
+
self, status_code: int, body: Any, content_type: str | None = None
|
|
282
|
+
) -> NoReturn:
|
|
283
|
+
# /api/v1 speaks RFC 9457; the legacy surface speaks {success, error}.
|
|
284
|
+
# Both land on the same exception classes, keyed off the status.
|
|
285
|
+
if is_problem_document(body, content_type):
|
|
286
|
+
raise error_from_problem(status_code, body)
|
|
242
287
|
|
|
243
|
-
def _raise_from_body(self, status_code: int, body: Any) -> NoReturn:
|
|
244
288
|
error = body.get("error") if isinstance(body, dict) else None
|
|
245
289
|
error = error if isinstance(error, dict) else {}
|
|
246
290
|
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/domains.py
CHANGED
|
@@ -12,6 +12,7 @@ if TYPE_CHECKING:
|
|
|
12
12
|
Body,
|
|
13
13
|
DomainListResponse,
|
|
14
14
|
DomainRecord,
|
|
15
|
+
DomainSetupSession,
|
|
15
16
|
DomainVerificationStatus,
|
|
16
17
|
)
|
|
17
18
|
|
|
@@ -62,6 +63,21 @@ class DomainsResource:
|
|
|
62
63
|
status: DomainVerificationStatus = self._client.unwrap(envelope)
|
|
63
64
|
return status
|
|
64
65
|
|
|
66
|
+
def start_setup(self, id: str) -> DomainSetupSession:
|
|
67
|
+
"""Start the guided DNS setup hand-off for a domain.
|
|
68
|
+
|
|
69
|
+
Returns the session as the route returns it: a ``connectUrl`` to open in
|
|
70
|
+
a browser, the ``token`` that url carries, and ``expiresAt``. Nothing is
|
|
71
|
+
derived or reshaped -- finishing setup means a person visiting that url
|
|
72
|
+
and authorising the change at their registrar, so the SDK's job is to
|
|
73
|
+
hand back the link, not to model the flow behind it.
|
|
74
|
+
"""
|
|
75
|
+
envelope = self._client.request(
|
|
76
|
+
method="POST", path=f"/api/domains/{encode_path_segment(id)}/dodomain-session"
|
|
77
|
+
)
|
|
78
|
+
session: DomainSetupSession = self._client.unwrap(envelope)
|
|
79
|
+
return session
|
|
80
|
+
|
|
65
81
|
def delete(self, id: str) -> None:
|
|
66
82
|
"""Delete a domain."""
|
|
67
83
|
self._client.request(method="DELETE", path=f"/api/domains/{encode_path_segment(id)}")
|