sendly-python 1.0.0__py3-none-any.whl → 1.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 +10 -0
- sendly/client.py +72 -25
- sendly/resources/_pagination.py +6 -3
- sendly/resources/campaigns.py +43 -0
- sendly/resources/contacts.py +99 -1
- sendly/resources/deliverability.py +109 -0
- sendly/resources/domains.py +188 -3
- sendly/resources/emails.py +20 -8
- sendly/resources/events.py +3 -1
- sendly/resources/lists.py +99 -6
- sendly/resources/mailboxes.py +186 -15
- sendly/resources/snippets.py +72 -0
- sendly/resources/suppression.py +81 -2
- sendly/resources/templates.py +89 -3
- sendly/resources/topics.py +109 -0
- sendly/resources/validation.py +99 -0
- sendly/resources/webhooks.py +113 -1
- sendly/resources/workflows.py +94 -0
- sendly/types.py +119 -1
- sendly_python-1.2.0.dist-info/METADATA +1387 -0
- sendly_python-1.2.0.dist-info/RECORD +33 -0
- sendly_python-1.0.0.dist-info/METADATA +0 -653
- sendly_python-1.0.0.dist-info/RECORD +0 -29
- {sendly_python-1.0.0.dist-info → sendly_python-1.2.0.dist-info}/WHEEL +0 -0
- {sendly_python-1.0.0.dist-info → sendly_python-1.2.0.dist-info}/licenses/LICENSE +0 -0
sendly/__init__.py
CHANGED
|
@@ -33,6 +33,7 @@ from sendly.errors import (
|
|
|
33
33
|
from sendly.resources.analytics import AnalyticsResource
|
|
34
34
|
from sendly.resources.campaigns import CampaignsResource
|
|
35
35
|
from sendly.resources.contacts import ContactsResource
|
|
36
|
+
from sendly.resources.deliverability import DeliverabilityResource
|
|
36
37
|
from sendly.resources.domains import DomainsResource
|
|
37
38
|
from sendly.resources.emails import EmailsResource
|
|
38
39
|
from sendly.resources.events import EventsResource
|
|
@@ -40,12 +41,16 @@ from sendly.resources.lists import ListsResource
|
|
|
40
41
|
from sendly.resources.mailboxes import MailboxesResource
|
|
41
42
|
from sendly.resources.projects import ProjectsResource
|
|
42
43
|
from sendly.resources.segments import SegmentsResource
|
|
44
|
+
from sendly.resources.snippets import SnippetsResource
|
|
43
45
|
from sendly.resources.suppression import SuppressionResource
|
|
44
46
|
from sendly.resources.templates import TemplatesResource
|
|
47
|
+
from sendly.resources.topics import TopicsResource
|
|
45
48
|
from sendly.resources.usage import UsageResource
|
|
49
|
+
from sendly.resources.validation import ValidationResource
|
|
46
50
|
from sendly.resources.verify import VerifyResource
|
|
47
51
|
from sendly.resources.webhooks import WebhooksResource
|
|
48
52
|
from sendly.resources.workflows import WorkflowsResource
|
|
53
|
+
from sendly.types import SendlyFile
|
|
49
54
|
from sendly.webhook_utils import DEFAULT_TOLERANCE_MS, construct_event, verify_signature
|
|
50
55
|
|
|
51
56
|
__version__ = SDK_VERSION
|
|
@@ -57,6 +62,7 @@ __all__ = [
|
|
|
57
62
|
"AnalyticsResource",
|
|
58
63
|
"CampaignsResource",
|
|
59
64
|
"ContactsResource",
|
|
65
|
+
"DeliverabilityResource",
|
|
60
66
|
"DomainsResource",
|
|
61
67
|
"EmailsResource",
|
|
62
68
|
"EventsResource",
|
|
@@ -69,14 +75,18 @@ __all__ = [
|
|
|
69
75
|
"SendlyConflictError",
|
|
70
76
|
"SendlyConnectionError",
|
|
71
77
|
"SendlyError",
|
|
78
|
+
"SendlyFile",
|
|
72
79
|
"SendlyNotFoundError",
|
|
73
80
|
"SendlyPermissionError",
|
|
74
81
|
"SendlyRateLimitError",
|
|
75
82
|
"SendlyServerError",
|
|
76
83
|
"SendlyValidationError",
|
|
84
|
+
"SnippetsResource",
|
|
77
85
|
"SuppressionResource",
|
|
78
86
|
"TemplatesResource",
|
|
87
|
+
"TopicsResource",
|
|
79
88
|
"UsageResource",
|
|
89
|
+
"ValidationResource",
|
|
80
90
|
"VerifyResource",
|
|
81
91
|
"WebhooksResource",
|
|
82
92
|
"WorkflowsResource",
|
sendly/client.py
CHANGED
|
@@ -36,6 +36,7 @@ from sendly.errors import (
|
|
|
36
36
|
from sendly.resources.analytics import AnalyticsResource
|
|
37
37
|
from sendly.resources.campaigns import CampaignsResource
|
|
38
38
|
from sendly.resources.contacts import ContactsResource
|
|
39
|
+
from sendly.resources.deliverability import DeliverabilityResource
|
|
39
40
|
from sendly.resources.domains import DomainsResource
|
|
40
41
|
from sendly.resources.emails import EmailsResource
|
|
41
42
|
from sendly.resources.events import EventsResource
|
|
@@ -43,12 +44,16 @@ from sendly.resources.lists import ListsResource
|
|
|
43
44
|
from sendly.resources.mailboxes import MailboxesResource
|
|
44
45
|
from sendly.resources.projects import ProjectsResource
|
|
45
46
|
from sendly.resources.segments import SegmentsResource
|
|
47
|
+
from sendly.resources.snippets import SnippetsResource
|
|
46
48
|
from sendly.resources.suppression import SuppressionResource
|
|
47
49
|
from sendly.resources.templates import TemplatesResource
|
|
50
|
+
from sendly.resources.topics import TopicsResource
|
|
48
51
|
from sendly.resources.usage import UsageResource
|
|
52
|
+
from sendly.resources.validation import ValidationResource
|
|
49
53
|
from sendly.resources.verify import VerifyResource
|
|
50
54
|
from sendly.resources.webhooks import WebhooksResource
|
|
51
55
|
from sendly.resources.workflows import WorkflowsResource
|
|
56
|
+
from sendly.types import SendlyFile
|
|
52
57
|
|
|
53
58
|
if TYPE_CHECKING:
|
|
54
59
|
from collections.abc import Mapping
|
|
@@ -59,7 +64,7 @@ if TYPE_CHECKING:
|
|
|
59
64
|
__all__ = ["DEFAULT_BASE_URL", "SDK_VERSION", "Sendly"]
|
|
60
65
|
|
|
61
66
|
#: Package version. Kept in sync with ``pyproject.toml``.
|
|
62
|
-
SDK_VERSION = "1.
|
|
67
|
+
SDK_VERSION = "1.2.0"
|
|
63
68
|
|
|
64
69
|
#: Default production API base. Override via ``base_url`` for staging/self-hosted.
|
|
65
70
|
DEFAULT_BASE_URL = "https://api.sendly.now"
|
|
@@ -137,6 +142,7 @@ class Sendly:
|
|
|
137
142
|
self.webhooks = WebhooksResource(self)
|
|
138
143
|
self.suppression = SuppressionResource(self)
|
|
139
144
|
self.lists = ListsResource(self)
|
|
145
|
+
self.snippets = SnippetsResource(self)
|
|
140
146
|
# Reads only -- the mailbox writes need a user, which an API key is not.
|
|
141
147
|
self.mailboxes = MailboxesResource(self)
|
|
142
148
|
# /api/v1 surface. Same client, same auth; bare resource bodies instead
|
|
@@ -147,6 +153,9 @@ class Sendly:
|
|
|
147
153
|
self.analytics = AnalyticsResource(self)
|
|
148
154
|
self.usage = UsageResource(self)
|
|
149
155
|
self.projects = ProjectsResource(self)
|
|
156
|
+
self.topics = TopicsResource(self)
|
|
157
|
+
self.validation = ValidationResource(self)
|
|
158
|
+
self.deliverability = DeliverabilityResource(self)
|
|
150
159
|
|
|
151
160
|
def request(
|
|
152
161
|
self,
|
|
@@ -166,30 +175,7 @@ class Sendly:
|
|
|
166
175
|
success body, or ``None`` for 204 / ``no_content``. Errors are raised as
|
|
167
176
|
:class:`SendlyError` subclasses based on status.
|
|
168
177
|
"""
|
|
169
|
-
|
|
170
|
-
request_headers: dict[str, str] = {
|
|
171
|
-
"Authorization": f"Bearer {self._api_key}",
|
|
172
|
-
"Accept": "application/json",
|
|
173
|
-
"User-Agent": USER_AGENT,
|
|
174
|
-
**self._default_headers,
|
|
175
|
-
**(dict(headers) if headers else {}),
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
content: str | None = None
|
|
179
|
-
if body is not None:
|
|
180
|
-
request_headers["Content-Type"] = "application/json"
|
|
181
|
-
content = json.dumps(body)
|
|
182
|
-
|
|
183
|
-
try:
|
|
184
|
-
response = self._client.request(
|
|
185
|
-
method,
|
|
186
|
-
url,
|
|
187
|
-
content=content,
|
|
188
|
-
headers=request_headers,
|
|
189
|
-
timeout=self._timeout,
|
|
190
|
-
)
|
|
191
|
-
except httpx.HTTPError as exc:
|
|
192
|
-
raise SendlyConnectionError(f"Sendly request failed: {exc}", exc) from exc
|
|
178
|
+
response = self._send(method=method, path=path, body=body, query=query, headers=headers)
|
|
193
179
|
|
|
194
180
|
# 204 No Content or caller-forced no-content (DELETE endpoints, which
|
|
195
181
|
# now respond 200 with an id body the SDK intentionally discards).
|
|
@@ -221,6 +207,67 @@ class Sendly:
|
|
|
221
207
|
|
|
222
208
|
return parsed
|
|
223
209
|
|
|
210
|
+
def request_bytes(
|
|
211
|
+
self,
|
|
212
|
+
*,
|
|
213
|
+
method: str,
|
|
214
|
+
path: str,
|
|
215
|
+
query: Query | None = None,
|
|
216
|
+
headers: Mapping[str, str] | None = None,
|
|
217
|
+
) -> SendlyFile:
|
|
218
|
+
"""Like :meth:`request`, for an operation whose success body is a file.
|
|
219
|
+
|
|
220
|
+
Returns the raw bytes with the response's own ``Content-Type`` and
|
|
221
|
+
``Content-Disposition``. A failure is still a JSON error document and is
|
|
222
|
+
raised as the same :class:`SendlyError` subclasses. Reading the body as
|
|
223
|
+
text, as :meth:`request` does, would mangle any file that is not UTF-8.
|
|
224
|
+
"""
|
|
225
|
+
response = self._send(
|
|
226
|
+
method=method, path=path, query=query, headers={"Accept": "*/*", **(headers or {})}
|
|
227
|
+
)
|
|
228
|
+
if not response.is_success:
|
|
229
|
+
self._raise_for_error(response)
|
|
230
|
+
return SendlyFile(
|
|
231
|
+
data=response.content,
|
|
232
|
+
content_type=response.headers.get("content-type"),
|
|
233
|
+
content_disposition=response.headers.get("content-disposition"),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def _send(
|
|
237
|
+
self,
|
|
238
|
+
*,
|
|
239
|
+
method: str,
|
|
240
|
+
path: str,
|
|
241
|
+
body: Body | None = None,
|
|
242
|
+
query: Query | None = None,
|
|
243
|
+
headers: Mapping[str, str] | None = None,
|
|
244
|
+
) -> httpx.Response:
|
|
245
|
+
"""Issue the request; errors and parsing are the caller's."""
|
|
246
|
+
url = self._build_url(path, query)
|
|
247
|
+
request_headers: dict[str, str] = {
|
|
248
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
249
|
+
"Accept": "application/json",
|
|
250
|
+
"User-Agent": USER_AGENT,
|
|
251
|
+
**self._default_headers,
|
|
252
|
+
**(dict(headers) if headers else {}),
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
content: str | None = None
|
|
256
|
+
if body is not None:
|
|
257
|
+
request_headers["Content-Type"] = "application/json"
|
|
258
|
+
content = json.dumps(body)
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
return self._client.request(
|
|
262
|
+
method,
|
|
263
|
+
url,
|
|
264
|
+
content=content,
|
|
265
|
+
headers=request_headers,
|
|
266
|
+
timeout=self._timeout,
|
|
267
|
+
)
|
|
268
|
+
except httpx.HTTPError as exc:
|
|
269
|
+
raise SendlyConnectionError(f"Sendly request failed: {exc}", exc) from exc
|
|
270
|
+
|
|
224
271
|
def unwrap(self, envelope: Any) -> Any:
|
|
225
272
|
"""Return the ``data`` field of a ``{success, data}`` envelope, else the
|
|
226
273
|
value unchanged. Centralizing this keeps resource code clean.
|
sendly/resources/_pagination.py
CHANGED
|
@@ -25,8 +25,11 @@ def iterate_cursor(
|
|
|
25
25
|
``fetch`` receives the query for one page — the caller's own filters, plus
|
|
26
26
|
the ``after`` cursor from page two onward — and returns the raw cursor
|
|
27
27
|
envelope. Iteration stops when ``has_more`` is false or ``next_cursor`` is
|
|
28
|
-
``None``,
|
|
29
|
-
|
|
28
|
+
``None``, when a page carries no ``data`` list, and when a page hands back
|
|
29
|
+
the very cursor it was given -- so a malformed or stuck response ends the
|
|
30
|
+
walk instead of looping forever. The last of those came from the two
|
|
31
|
+
hand-rolled walkers this helper absorbed in 1.1; consolidating them must not
|
|
32
|
+
drop a stop condition the resources that had it were relying on.
|
|
30
33
|
|
|
31
34
|
The caller's filters are held fixed for the whole walk on purpose: changing
|
|
32
35
|
them mid-pagination invalidates the cursor and the API answers ``422
|
|
@@ -44,6 +47,6 @@ def iterate_cursor(
|
|
|
44
47
|
if not page.get("has_more"):
|
|
45
48
|
return
|
|
46
49
|
cursor = page.get("next_cursor")
|
|
47
|
-
if not cursor:
|
|
50
|
+
if not cursor or cursor == params.get("after"):
|
|
48
51
|
return
|
|
49
52
|
params = {**params, "after": cursor}
|
sendly/resources/campaigns.py
CHANGED
|
@@ -14,8 +14,10 @@ if TYPE_CHECKING:
|
|
|
14
14
|
from sendly.types import (
|
|
15
15
|
Body,
|
|
16
16
|
CampaignDeleted,
|
|
17
|
+
CampaignFailureListV1,
|
|
17
18
|
CampaignList,
|
|
18
19
|
CampaignRecord,
|
|
20
|
+
CampaignRetryFailedV1,
|
|
19
21
|
CampaignStats,
|
|
20
22
|
JSONDict,
|
|
21
23
|
Query,
|
|
@@ -133,3 +135,44 @@ class CampaignsResource:
|
|
|
133
135
|
method="GET", path=f"/api/v1/campaigns/{encode_path_segment(id)}/stats"
|
|
134
136
|
)
|
|
135
137
|
return response
|
|
138
|
+
|
|
139
|
+
def list_failures(self, id: str, query: Query | None = None) -> CampaignFailureListV1:
|
|
140
|
+
"""The recipients this campaign did not reach, and why.
|
|
141
|
+
|
|
142
|
+
:meth:`stats` says how many sends failed; only this says who. ``reason``
|
|
143
|
+
comes from a fixed vocabulary rather than the underlying error text, so
|
|
144
|
+
it is stable enough to branch on, and is ``None`` on rows recorded
|
|
145
|
+
before reasons were captured.
|
|
146
|
+
|
|
147
|
+
Cursor-paginated (``limit`` / ``after``) like every other v1 list, but
|
|
148
|
+
uniquely it also carries ``total``: :meth:`retry_failed` acts on that
|
|
149
|
+
number, and ``has_more`` alone cannot tell you whether 3 or 30,000 sends
|
|
150
|
+
failed.
|
|
151
|
+
"""
|
|
152
|
+
response: CampaignFailureListV1 = self._client.request(
|
|
153
|
+
method="GET",
|
|
154
|
+
path=f"/api/v1/campaigns/{encode_path_segment(id)}/failures",
|
|
155
|
+
query=query,
|
|
156
|
+
)
|
|
157
|
+
return response
|
|
158
|
+
|
|
159
|
+
def iter_list_failures(self, id: str, query: Query | None = None) -> Iterator[JSONDict]:
|
|
160
|
+
"""Iterate every failed send across pages, one recipient at a time."""
|
|
161
|
+
return iterate_cursor(lambda params: self.list_failures(id, params), query)
|
|
162
|
+
|
|
163
|
+
def retry_failed(self, id: str) -> CampaignRetryFailedV1:
|
|
164
|
+
"""Re-drive only the recipients whose send failed.
|
|
165
|
+
|
|
166
|
+
Nobody who already received the campaign is mailed a second time: each
|
|
167
|
+
ledger row is claimed before it is touched, and a row whose email exists
|
|
168
|
+
already is re-queued rather than re-sent.
|
|
169
|
+
|
|
170
|
+
The walk runs in the background, so this returns as soon as it is
|
|
171
|
+
queued, reporting ``queued`` -- how many failed rows it was started for.
|
|
172
|
+
Only a ``SENT`` campaign qualifies (400 ``validation_error`` otherwise),
|
|
173
|
+
and a retry already running answers 409 ``conflict``. Takes no body.
|
|
174
|
+
"""
|
|
175
|
+
response: CampaignRetryFailedV1 = self._client.request(
|
|
176
|
+
method="POST", path=f"/api/v1/campaigns/{encode_path_segment(id)}/retry-failed"
|
|
177
|
+
)
|
|
178
|
+
return response
|
sendly/resources/contacts.py
CHANGED
|
@@ -5,20 +5,35 @@ from __future__ import annotations
|
|
|
5
5
|
from typing import TYPE_CHECKING
|
|
6
6
|
|
|
7
7
|
from sendly.resources._helpers import encode_path_segment, idempotency_headers
|
|
8
|
+
from sendly.resources._pagination import iterate_cursor
|
|
8
9
|
|
|
9
10
|
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
|
|
10
13
|
from sendly.client import Sendly
|
|
11
14
|
from sendly.types import (
|
|
12
15
|
Body,
|
|
16
|
+
ContactDeletedV1,
|
|
13
17
|
ContactListResponse,
|
|
18
|
+
ContactListV1,
|
|
14
19
|
ContactRecord,
|
|
20
|
+
ContactTopicPreferencesV1,
|
|
21
|
+
ContactV1,
|
|
15
22
|
JSONDict,
|
|
16
23
|
Query,
|
|
17
24
|
)
|
|
18
25
|
|
|
19
26
|
|
|
20
27
|
class ContactsResource:
|
|
21
|
-
"""Create, query, and manage contacts.
|
|
28
|
+
"""Create, query, and manage contacts, on both surfaces.
|
|
29
|
+
|
|
30
|
+
The unsuffixed methods speak the legacy ``/api/*`` dialect — camelCase
|
|
31
|
+
bodies inside a ``{success, data}`` envelope the SDK unwraps. The
|
|
32
|
+
``_v1``-suffixed methods speak ``/api/v1``: bare snake_case bodies, cursor
|
|
33
|
+
pagination, and RFC 9457 problem documents on error. Both answer the same
|
|
34
|
+
questions, so the suffix is there to keep a call site from confusing one for
|
|
35
|
+
the other.
|
|
36
|
+
"""
|
|
22
37
|
|
|
23
38
|
def __init__(self, client: Sendly) -> None:
|
|
24
39
|
self._client = client
|
|
@@ -91,3 +106,86 @@ class ContactsResource:
|
|
|
91
106
|
self._client.request(
|
|
92
107
|
method="DELETE", path=f"/api/contacts/{encode_path_segment(id)}", no_content=True
|
|
93
108
|
)
|
|
109
|
+
|
|
110
|
+
def list_v1(self, query: Query | None = None) -> ContactListV1:
|
|
111
|
+
"""List contacts on the ``/api/v1`` surface.
|
|
112
|
+
|
|
113
|
+
Accepts ``limit`` (1-100, default 20), ``after`` (opaque cursor),
|
|
114
|
+
``search`` (case-insensitive substring on the address) and
|
|
115
|
+
``subscribed`` (the string ``"true"`` or ``"false"``), and answers
|
|
116
|
+
``{data, has_more, next_cursor}``. Hold the filters steady across one
|
|
117
|
+
walk — the cursor encodes them, and changing one mid-pagination is
|
|
118
|
+
answered with 422 ``validation_error`` telling you to restart from the
|
|
119
|
+
first page.
|
|
120
|
+
"""
|
|
121
|
+
response: ContactListV1 = self._client.request(
|
|
122
|
+
method="GET", path="/api/v1/contacts", query=query
|
|
123
|
+
)
|
|
124
|
+
return response
|
|
125
|
+
|
|
126
|
+
def iter_list_v1(self, query: Query | None = None) -> Iterator[JSONDict]:
|
|
127
|
+
"""Iterate every v1 contact across pages, following the cursor for you."""
|
|
128
|
+
return iterate_cursor(self.list_v1, query)
|
|
129
|
+
|
|
130
|
+
def create_v1(self, body: Body) -> ContactV1:
|
|
131
|
+
"""Create a contact. Requires ``email``.
|
|
132
|
+
|
|
133
|
+
``subscribed`` defaults to true server-side, and ``custom_fields`` is
|
|
134
|
+
arbitrary JSON that templates read back as ``{{ variables }}``.
|
|
135
|
+
"""
|
|
136
|
+
response: ContactV1 = self._client.request(
|
|
137
|
+
method="POST", path="/api/v1/contacts", body=body
|
|
138
|
+
)
|
|
139
|
+
return response
|
|
140
|
+
|
|
141
|
+
def get_v1(self, id: str) -> ContactV1:
|
|
142
|
+
"""Fetch a single contact by id.
|
|
143
|
+
|
|
144
|
+
v1 has no lookup-by-address route — reach a contact you only know the
|
|
145
|
+
email of through :meth:`list_v1`'s ``search`` filter.
|
|
146
|
+
"""
|
|
147
|
+
response: ContactV1 = self._client.request(
|
|
148
|
+
method="GET", path=f"/api/v1/contacts/{encode_path_segment(id)}"
|
|
149
|
+
)
|
|
150
|
+
return response
|
|
151
|
+
|
|
152
|
+
def update_v1(self, id: str, body: Body) -> ContactV1:
|
|
153
|
+
"""Patch a contact's ``subscribed`` flag or ``custom_fields``.
|
|
154
|
+
|
|
155
|
+
``email`` is deliberately not patchable: an address is the contact's
|
|
156
|
+
identity on this surface, and rewriting it in place would change who
|
|
157
|
+
every earlier send was addressed to. Create the new address instead.
|
|
158
|
+
|
|
159
|
+
``custom_fields`` is **replaced, not merged** — the object you send
|
|
160
|
+
becomes the whole of it, so read the contact and send back every key you
|
|
161
|
+
mean to keep. A partial object silently drops the rest.
|
|
162
|
+
"""
|
|
163
|
+
response: ContactV1 = self._client.request(
|
|
164
|
+
method="PATCH", path=f"/api/v1/contacts/{encode_path_segment(id)}", body=body
|
|
165
|
+
)
|
|
166
|
+
return response
|
|
167
|
+
|
|
168
|
+
def delete_v1(self, id: str) -> ContactDeletedV1:
|
|
169
|
+
"""Delete a contact, returning the ``{id, deleted}`` confirmation body.
|
|
170
|
+
|
|
171
|
+
Unlike the legacy :meth:`delete`, the acknowledgement is handed back
|
|
172
|
+
rather than discarded.
|
|
173
|
+
"""
|
|
174
|
+
response: ContactDeletedV1 = self._client.request(
|
|
175
|
+
method="DELETE", path=f"/api/v1/contacts/{encode_path_segment(id)}"
|
|
176
|
+
)
|
|
177
|
+
return response
|
|
178
|
+
|
|
179
|
+
def topic_preferences(self, id: str) -> ContactTopicPreferencesV1:
|
|
180
|
+
"""Read everything this contact has said about what they want.
|
|
181
|
+
|
|
182
|
+
The top-level ``subscribed`` is the global marketing opt-out and
|
|
183
|
+
outranks every topic: false means no marketing reaches them whatever the
|
|
184
|
+
topic rows say. Each topic's own ``subscribed`` is the effective answer
|
|
185
|
+
the send path reaches today, with the topic's ``default_opt_in`` already
|
|
186
|
+
folded in, so a contact who has never answered still reads correctly.
|
|
187
|
+
"""
|
|
188
|
+
response: ContactTopicPreferencesV1 = self._client.request(
|
|
189
|
+
method="GET", path=f"/api/v1/contacts/{encode_path_segment(id)}/topics"
|
|
190
|
+
)
|
|
191
|
+
return response
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Deliverability resource (``/api/v1``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from sendly.resources._pagination import iterate_cursor
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
|
|
12
|
+
from sendly.client import Sendly
|
|
13
|
+
from sendly.types import (
|
|
14
|
+
DeliverabilityDiagnosisV1,
|
|
15
|
+
DmarcReportListV1,
|
|
16
|
+
JSONDict,
|
|
17
|
+
Query,
|
|
18
|
+
RecipientDomainStatsListV1,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DeliverabilityResource:
|
|
23
|
+
"""Why mail from your domains is, or is not, arriving.
|
|
24
|
+
|
|
25
|
+
Responses are bare ``/api/v1`` bodies -- no ``{success, data}`` envelope --
|
|
26
|
+
and errors are RFC 9457 problem documents.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, client: Sendly) -> None:
|
|
30
|
+
self._client = client
|
|
31
|
+
|
|
32
|
+
def diagnose(self, query: Query) -> DeliverabilityDiagnosisV1:
|
|
33
|
+
"""Diagnose one of your SENDING domains.
|
|
34
|
+
|
|
35
|
+
Answers with its DNS identity, the project's recent delivery outcomes,
|
|
36
|
+
optionally one recipient's suppression state, and the ``findings`` drawn
|
|
37
|
+
from them, worst first. Branch on a finding's ``code``, never on its
|
|
38
|
+
prose.
|
|
39
|
+
|
|
40
|
+
``domain`` is required -- the endpoint answers about one domain. The
|
|
41
|
+
optional ``address`` is a RECIPIENT to check alongside it, because being
|
|
42
|
+
suppressed is the single most common reason one person stops receiving
|
|
43
|
+
mail while everyone else still does. ``window_days`` (1-30, default 7)
|
|
44
|
+
only moves the delivery counters.
|
|
45
|
+
|
|
46
|
+
Nothing here is looked up live: the DNS statuses are the verification
|
|
47
|
+
refresh job's cached results, and ``identity.last_checked_at`` says when
|
|
48
|
+
they were filled. ``recent_delivery`` is project-wide rather than
|
|
49
|
+
per-domain -- its own ``scope`` field says so -- because an email row
|
|
50
|
+
records no sending domain.
|
|
51
|
+
"""
|
|
52
|
+
response: DeliverabilityDiagnosisV1 = self._client.request(
|
|
53
|
+
method="GET", path="/api/v1/deliverability/diagnose", query=query
|
|
54
|
+
)
|
|
55
|
+
return response
|
|
56
|
+
|
|
57
|
+
def list_domain_stats(self, query: Query | None = None) -> RecipientDomainStatsListV1:
|
|
58
|
+
"""Delivery outcomes by RECIPIENT domain and UTC day, newest day first.
|
|
59
|
+
|
|
60
|
+
These are the domains you send TO -- ``gmail.com``, ``outlook.com`` --
|
|
61
|
+
not the domains you send FROM. That is the axis :meth:`diagnose` cannot
|
|
62
|
+
report: its project-wide rates hide the case that matters most, one
|
|
63
|
+
recipient domain refusing nearly everything while the rest of your mail
|
|
64
|
+
is healthy.
|
|
65
|
+
|
|
66
|
+
Cursor-paginated on ``limit`` + ``after``. The counts come from an hourly
|
|
67
|
+
rollup job over a rolling 30-day window, not from a query run on request;
|
|
68
|
+
each row's ``computed_at`` says when it was last rebuilt. No rate is
|
|
69
|
+
published, because a rate over three sends is not information.
|
|
70
|
+
"""
|
|
71
|
+
response: RecipientDomainStatsListV1 = self._client.request(
|
|
72
|
+
method="GET", path="/api/v1/deliverability/domains", query=query
|
|
73
|
+
)
|
|
74
|
+
return response
|
|
75
|
+
|
|
76
|
+
def iter_list_domain_stats(self, query: Query | None = None) -> Iterator[JSONDict]:
|
|
77
|
+
"""Iterate every recipient-domain row across pages, one day-and-domain at a time."""
|
|
78
|
+
return iterate_cursor(self.list_domain_stats, query)
|
|
79
|
+
|
|
80
|
+
def list_dmarc_reports(self, query: Query | None = None) -> DmarcReportListV1:
|
|
81
|
+
"""DMARC aggregate (RUA) reports about your domains, newest window first.
|
|
82
|
+
|
|
83
|
+
Cursor-paginated on ``limit`` + ``after``.
|
|
84
|
+
|
|
85
|
+
An empty list is the correct answer, not a bug, until a policy domain is
|
|
86
|
+
registered in this project and its DMARC record names an address we
|
|
87
|
+
receive: only reports about a registered domain are stored, and receivers
|
|
88
|
+
send them on their own schedule (typically once a day).
|
|
89
|
+
|
|
90
|
+
``intake_configured`` says which kind of empty you are looking at. When
|
|
91
|
+
it is ``False`` this deployment has no DMARC report intake mailbox at
|
|
92
|
+
all, so no report can ever arrive and an empty ``data`` means the
|
|
93
|
+
feature is off -- not that your domains are clean. The two are otherwise
|
|
94
|
+
indistinguishable, so read the flag before reporting "no DMARC failures"
|
|
95
|
+
to anyone.
|
|
96
|
+
|
|
97
|
+
``pass_count`` counts DMARC ALIGNMENT taken from ``policy_evaluated``,
|
|
98
|
+
not raw authentication results -- a message can pass SPF for a domain
|
|
99
|
+
that is not the one in its From header, which is exactly the case DMARC
|
|
100
|
+
exists to catch.
|
|
101
|
+
"""
|
|
102
|
+
response: DmarcReportListV1 = self._client.request(
|
|
103
|
+
method="GET", path="/api/v1/deliverability/dmarc", query=query
|
|
104
|
+
)
|
|
105
|
+
return response
|
|
106
|
+
|
|
107
|
+
def iter_list_dmarc_reports(self, query: Query | None = None) -> Iterator[JSONDict]:
|
|
108
|
+
"""Iterate every DMARC report across pages, one report at a time."""
|
|
109
|
+
return iterate_cursor(self.list_dmarc_reports, query)
|