sendly-python 0.2.0__py3-none-any.whl → 1.1.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 +15 -1
- sendly/client.py +14 -1
- 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 +146 -3
- sendly/resources/emails.py +70 -11
- sendly/resources/events.py +3 -1
- sendly/resources/lists.py +99 -6
- sendly/resources/mailboxes.py +133 -0
- sendly/resources/projects.py +33 -0
- 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 +118 -1
- sendly_python-1.1.0.dist-info/METADATA +1323 -0
- sendly_python-1.1.0.dist-info/RECORD +33 -0
- sendly_python-0.2.0.dist-info/METADATA +0 -483
- sendly_python-0.2.0.dist-info/RECORD +0 -27
- {sendly_python-0.2.0.dist-info → sendly_python-1.1.0.dist-info}/WHEEL +0 -0
- {sendly_python-0.2.0.dist-info → sendly_python-1.1.0.dist-info}/licenses/LICENSE +0 -0
sendly/__init__.py
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
Example:
|
|
4
4
|
>>> from sendly import Sendly
|
|
5
5
|
>>> sendly = Sendly() # reads SENDLY_API_KEY
|
|
6
|
-
>>> sendly.emails.send(
|
|
6
|
+
>>> receipt = sendly.emails.send(
|
|
7
7
|
... {"from": "a@b.com", "to": "c@d.com", "subject": "hi", "body": "<p>hi</p>"}
|
|
8
8
|
... )
|
|
9
|
+
>>> receipt["status"] # a real delivery state; poll emails.get(receipt["id"])
|
|
10
|
+
'PENDING'
|
|
9
11
|
|
|
10
12
|
The same client also speaks the ``/api/v1`` surface — campaigns, segments,
|
|
11
13
|
workflows, analytics, usage, and the v1 event methods:
|
|
@@ -31,14 +33,20 @@ from sendly.errors import (
|
|
|
31
33
|
from sendly.resources.analytics import AnalyticsResource
|
|
32
34
|
from sendly.resources.campaigns import CampaignsResource
|
|
33
35
|
from sendly.resources.contacts import ContactsResource
|
|
36
|
+
from sendly.resources.deliverability import DeliverabilityResource
|
|
34
37
|
from sendly.resources.domains import DomainsResource
|
|
35
38
|
from sendly.resources.emails import EmailsResource
|
|
36
39
|
from sendly.resources.events import EventsResource
|
|
37
40
|
from sendly.resources.lists import ListsResource
|
|
41
|
+
from sendly.resources.mailboxes import MailboxesResource
|
|
42
|
+
from sendly.resources.projects import ProjectsResource
|
|
38
43
|
from sendly.resources.segments import SegmentsResource
|
|
44
|
+
from sendly.resources.snippets import SnippetsResource
|
|
39
45
|
from sendly.resources.suppression import SuppressionResource
|
|
40
46
|
from sendly.resources.templates import TemplatesResource
|
|
47
|
+
from sendly.resources.topics import TopicsResource
|
|
41
48
|
from sendly.resources.usage import UsageResource
|
|
49
|
+
from sendly.resources.validation import ValidationResource
|
|
42
50
|
from sendly.resources.verify import VerifyResource
|
|
43
51
|
from sendly.resources.webhooks import WebhooksResource
|
|
44
52
|
from sendly.resources.workflows import WorkflowsResource
|
|
@@ -53,10 +61,13 @@ __all__ = [
|
|
|
53
61
|
"AnalyticsResource",
|
|
54
62
|
"CampaignsResource",
|
|
55
63
|
"ContactsResource",
|
|
64
|
+
"DeliverabilityResource",
|
|
56
65
|
"DomainsResource",
|
|
57
66
|
"EmailsResource",
|
|
58
67
|
"EventsResource",
|
|
59
68
|
"ListsResource",
|
|
69
|
+
"MailboxesResource",
|
|
70
|
+
"ProjectsResource",
|
|
60
71
|
"SegmentsResource",
|
|
61
72
|
"Sendly",
|
|
62
73
|
"SendlyAuthenticationError",
|
|
@@ -68,9 +79,12 @@ __all__ = [
|
|
|
68
79
|
"SendlyRateLimitError",
|
|
69
80
|
"SendlyServerError",
|
|
70
81
|
"SendlyValidationError",
|
|
82
|
+
"SnippetsResource",
|
|
71
83
|
"SuppressionResource",
|
|
72
84
|
"TemplatesResource",
|
|
85
|
+
"TopicsResource",
|
|
73
86
|
"UsageResource",
|
|
87
|
+
"ValidationResource",
|
|
74
88
|
"VerifyResource",
|
|
75
89
|
"WebhooksResource",
|
|
76
90
|
"WorkflowsResource",
|
sendly/client.py
CHANGED
|
@@ -36,14 +36,20 @@ 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
|
|
42
43
|
from sendly.resources.lists import ListsResource
|
|
44
|
+
from sendly.resources.mailboxes import MailboxesResource
|
|
45
|
+
from sendly.resources.projects import ProjectsResource
|
|
43
46
|
from sendly.resources.segments import SegmentsResource
|
|
47
|
+
from sendly.resources.snippets import SnippetsResource
|
|
44
48
|
from sendly.resources.suppression import SuppressionResource
|
|
45
49
|
from sendly.resources.templates import TemplatesResource
|
|
50
|
+
from sendly.resources.topics import TopicsResource
|
|
46
51
|
from sendly.resources.usage import UsageResource
|
|
52
|
+
from sendly.resources.validation import ValidationResource
|
|
47
53
|
from sendly.resources.verify import VerifyResource
|
|
48
54
|
from sendly.resources.webhooks import WebhooksResource
|
|
49
55
|
from sendly.resources.workflows import WorkflowsResource
|
|
@@ -57,7 +63,7 @@ if TYPE_CHECKING:
|
|
|
57
63
|
__all__ = ["DEFAULT_BASE_URL", "SDK_VERSION", "Sendly"]
|
|
58
64
|
|
|
59
65
|
#: Package version. Kept in sync with ``pyproject.toml``.
|
|
60
|
-
SDK_VERSION = "
|
|
66
|
+
SDK_VERSION = "1.1.0"
|
|
61
67
|
|
|
62
68
|
#: Default production API base. Override via ``base_url`` for staging/self-hosted.
|
|
63
69
|
DEFAULT_BASE_URL = "https://api.sendly.now"
|
|
@@ -135,6 +141,9 @@ class Sendly:
|
|
|
135
141
|
self.webhooks = WebhooksResource(self)
|
|
136
142
|
self.suppression = SuppressionResource(self)
|
|
137
143
|
self.lists = ListsResource(self)
|
|
144
|
+
self.snippets = SnippetsResource(self)
|
|
145
|
+
# Reads only -- the mailbox writes need a user, which an API key is not.
|
|
146
|
+
self.mailboxes = MailboxesResource(self)
|
|
138
147
|
# /api/v1 surface. Same client, same auth; bare resource bodies instead
|
|
139
148
|
# of the legacy {success, data} envelope, and RFC 9457 problem errors.
|
|
140
149
|
self.campaigns = CampaignsResource(self)
|
|
@@ -142,6 +151,10 @@ class Sendly:
|
|
|
142
151
|
self.workflows = WorkflowsResource(self)
|
|
143
152
|
self.analytics = AnalyticsResource(self)
|
|
144
153
|
self.usage = UsageResource(self)
|
|
154
|
+
self.projects = ProjectsResource(self)
|
|
155
|
+
self.topics = TopicsResource(self)
|
|
156
|
+
self.validation = ValidationResource(self)
|
|
157
|
+
self.deliverability = DeliverabilityResource(self)
|
|
145
158
|
|
|
146
159
|
def request(
|
|
147
160
|
self,
|
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)
|
sendly/resources/domains.py
CHANGED
|
@@ -5,19 +5,36 @@ from __future__ import annotations
|
|
|
5
5
|
from typing import TYPE_CHECKING
|
|
6
6
|
|
|
7
7
|
from sendly.resources._helpers import encode_path_segment
|
|
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
|
+
DomainDeletedV1,
|
|
13
17
|
DomainListResponse,
|
|
18
|
+
DomainListV1,
|
|
14
19
|
DomainRecord,
|
|
20
|
+
DomainSetupSession,
|
|
21
|
+
DomainV1,
|
|
15
22
|
DomainVerificationStatus,
|
|
23
|
+
JSONDict,
|
|
24
|
+
Query,
|
|
16
25
|
)
|
|
17
26
|
|
|
18
27
|
|
|
19
28
|
class DomainsResource:
|
|
20
|
-
"""Register and verify sending domains.
|
|
29
|
+
"""Register and verify sending domains, on both surfaces.
|
|
30
|
+
|
|
31
|
+
The unsuffixed methods speak the legacy ``/api/*`` dialect -- camelCase
|
|
32
|
+
bodies inside a ``{success, data}`` envelope the SDK unwraps. The
|
|
33
|
+
``_v1``-suffixed methods speak ``/api/v1``: bare snake_case bodies, cursor
|
|
34
|
+
pagination, and RFC 9457 problem documents on error. Both answer the same
|
|
35
|
+
questions, so the suffix is there to keep a call site from confusing one for
|
|
36
|
+
the other.
|
|
37
|
+
"""
|
|
21
38
|
|
|
22
39
|
def __init__(self, client: Sendly) -> None:
|
|
23
40
|
self._client = client
|
|
@@ -27,7 +44,12 @@ class DomainsResource:
|
|
|
27
44
|
|
|
28
45
|
Pass ``region`` to pin this domain to a specific AWS SES region. On the
|
|
29
46
|
first domain for a project this also locks the project's region;
|
|
30
|
-
subsequent calls must match.
|
|
47
|
+
subsequent calls must match.
|
|
48
|
+
|
|
49
|
+
The response carries ``dkimTokens`` -- the SES DKIM tokens to publish as
|
|
50
|
+
CNAME records before the domain can verify -- alongside ``dkimStatus``,
|
|
51
|
+
``spfStatus`` and ``dmarcStatus``, each the result of the last DNS check
|
|
52
|
+
for that record type.
|
|
31
53
|
"""
|
|
32
54
|
envelope = self._client.request(method="POST", path="/api/domains", body=body)
|
|
33
55
|
record: DomainRecord = self._client.unwrap(envelope)
|
|
@@ -47,7 +69,14 @@ class DomainsResource:
|
|
|
47
69
|
return record
|
|
48
70
|
|
|
49
71
|
def verify(self, id: str) -> DomainVerificationStatus:
|
|
50
|
-
"""Trigger SES verification for a domain.
|
|
72
|
+
"""Trigger SES verification for a domain.
|
|
73
|
+
|
|
74
|
+
``status`` is SES's own raw DKIM verification state (``Success``,
|
|
75
|
+
``Pending``), while ``dkimStatus``, ``spfStatus`` and ``dmarcStatus``
|
|
76
|
+
are this platform's own DNS check per record type. ``tokens`` carries
|
|
77
|
+
the DKIM tokens SES has still to report and is absent once verification
|
|
78
|
+
has resolved.
|
|
79
|
+
"""
|
|
51
80
|
envelope = self._client.request(
|
|
52
81
|
method="POST", path=f"/api/domains/{encode_path_segment(id)}/verify"
|
|
53
82
|
)
|
|
@@ -62,6 +91,120 @@ class DomainsResource:
|
|
|
62
91
|
status: DomainVerificationStatus = self._client.unwrap(envelope)
|
|
63
92
|
return status
|
|
64
93
|
|
|
94
|
+
def start_setup(self, id: str) -> DomainSetupSession:
|
|
95
|
+
"""Start the guided DNS setup hand-off for a domain.
|
|
96
|
+
|
|
97
|
+
Returns the session as the route returns it: a ``connectUrl`` to open in
|
|
98
|
+
a browser, the ``token`` that url carries, and ``expiresAt``. Nothing is
|
|
99
|
+
derived or reshaped -- finishing setup means a person visiting that url
|
|
100
|
+
and authorising the change at their registrar, so the SDK's job is to
|
|
101
|
+
hand back the link, not to model the flow behind it.
|
|
102
|
+
"""
|
|
103
|
+
envelope = self._client.request(
|
|
104
|
+
method="POST", path=f"/api/domains/{encode_path_segment(id)}/dodomain-session"
|
|
105
|
+
)
|
|
106
|
+
session: DomainSetupSession = self._client.unwrap(envelope)
|
|
107
|
+
return session
|
|
108
|
+
|
|
109
|
+
def assign_stream(self, id: str, body: Body) -> DomainRecord:
|
|
110
|
+
"""Assign this sending identity to transactional or marketing traffic.
|
|
111
|
+
|
|
112
|
+
Streams are enforced, not labelled: once assigned, a send of the other
|
|
113
|
+
kind from this identity is refused with 403 -- which is what keeps a
|
|
114
|
+
campaign's complaint rate off the identity your password resets go out
|
|
115
|
+
on. Pass ``stream: None`` to unassign, returning it to carrying both.
|
|
116
|
+
|
|
117
|
+
``streamDefault`` demotes whichever identity currently holds the default
|
|
118
|
+
for that stream, and ``defaultFromAddress`` has to be an address on this
|
|
119
|
+
identity's own host. Every field is optional; an omitted one is left
|
|
120
|
+
alone.
|
|
121
|
+
|
|
122
|
+
Legacy dialect: camelCase body, and the updated domain arrives inside
|
|
123
|
+
the ``{success, data}`` envelope this method unwraps for you.
|
|
124
|
+
"""
|
|
125
|
+
envelope = self._client.request(
|
|
126
|
+
method="PATCH", path=f"/api/domains/{encode_path_segment(id)}", body=body
|
|
127
|
+
)
|
|
128
|
+
record: DomainRecord = self._client.unwrap(envelope)
|
|
129
|
+
return record
|
|
130
|
+
|
|
65
131
|
def delete(self, id: str) -> None:
|
|
66
132
|
"""Delete a domain."""
|
|
67
133
|
self._client.request(method="DELETE", path=f"/api/domains/{encode_path_segment(id)}")
|
|
134
|
+
|
|
135
|
+
def list_v1(self, query: Query | None = None) -> DomainListV1:
|
|
136
|
+
"""List sending domains, newest first.
|
|
137
|
+
|
|
138
|
+
Accepts ``limit`` (1-100, default 20) and ``after`` (opaque cursor), and
|
|
139
|
+
answers ``{data, has_more, next_cursor}``. :meth:`iter_list_v1` drives
|
|
140
|
+
that walk for you.
|
|
141
|
+
|
|
142
|
+
``verified`` is SES's verdict on the identity and is what decides
|
|
143
|
+
whether mail can leave from this domain; ``dkim_verified`` is a separate
|
|
144
|
+
fact -- what the DNS health refresh last read for the DKIM records -- so
|
|
145
|
+
the two disagree while a re-check is in flight and neither is a spelling
|
|
146
|
+
of the other.
|
|
147
|
+
"""
|
|
148
|
+
response: DomainListV1 = self._client.request(
|
|
149
|
+
method="GET", path="/api/v1/domains", query=query
|
|
150
|
+
)
|
|
151
|
+
return response
|
|
152
|
+
|
|
153
|
+
def iter_list_v1(self, query: Query | None = None) -> Iterator[JSONDict]:
|
|
154
|
+
"""Iterate every sending domain across pages, following the cursor for you."""
|
|
155
|
+
return iterate_cursor(self.list_v1, query)
|
|
156
|
+
|
|
157
|
+
def create_v1(self, body: Body) -> DomainV1:
|
|
158
|
+
"""Register a sending domain and start SES DKIM verification.
|
|
159
|
+
|
|
160
|
+
The identity comes back with ``verified`` false -- nothing is verified
|
|
161
|
+
until the DKIM records are published in the domain's own DNS and SES
|
|
162
|
+
resolves them, so poll :meth:`verify_v1` after publishing them.
|
|
163
|
+
|
|
164
|
+
The first domain a project adds LOCKS the project's SES ``region``;
|
|
165
|
+
every later domain must match it. ``stream_default`` requires
|
|
166
|
+
``stream``, and sending it alone is answered with 422
|
|
167
|
+
``validation_error`` rather than ignored.
|
|
168
|
+
"""
|
|
169
|
+
response: DomainV1 = self._client.request(method="POST", path="/api/v1/domains", body=body)
|
|
170
|
+
return response
|
|
171
|
+
|
|
172
|
+
def get_v1(self, id: str) -> DomainV1:
|
|
173
|
+
"""Fetch a single sending domain by id."""
|
|
174
|
+
response: DomainV1 = self._client.request(
|
|
175
|
+
method="GET", path=f"/api/v1/domains/{encode_path_segment(id)}"
|
|
176
|
+
)
|
|
177
|
+
return response
|
|
178
|
+
|
|
179
|
+
def verify_v1(self, id: str) -> DomainV1:
|
|
180
|
+
"""Re-read the domain's state from SES and DNS, and return it refreshed.
|
|
181
|
+
|
|
182
|
+
This does not verify anything and changes none of the domain's own
|
|
183
|
+
fields. Verification happens in the domain's DNS, when its owner
|
|
184
|
+
publishes the DKIM records SES minted at creation, and Amazon decides
|
|
185
|
+
when those resolve. What this call does is ask SES what it currently
|
|
186
|
+
sees, re-check SPF and DMARC, and persist that answer -- so a caller
|
|
187
|
+
polling after a DNS change learns the outcome without waiting for the
|
|
188
|
+
periodic sweep. Calling it on a domain whose records are not published
|
|
189
|
+
yet is not an error and does not hurry anything.
|
|
190
|
+
|
|
191
|
+
A POST rather than a GET because the refreshed state is persisted and a
|
|
192
|
+
verified/unverified transition notifies the project.
|
|
193
|
+
"""
|
|
194
|
+
response: DomainV1 = self._client.request(
|
|
195
|
+
method="POST", path=f"/api/v1/domains/{encode_path_segment(id)}/verify"
|
|
196
|
+
)
|
|
197
|
+
return response
|
|
198
|
+
|
|
199
|
+
def delete_v1(self, id: str) -> DomainDeletedV1:
|
|
200
|
+
"""Remove a sending domain. Returns the ``{id, deleted}`` confirmation body.
|
|
201
|
+
|
|
202
|
+
Refused with 409 ``conflict`` while a template, workflow step or active
|
|
203
|
+
campaign still sends from an address on this host. The SES identity goes
|
|
204
|
+
too unless another project holds the same host -- and its DKIM keys with
|
|
205
|
+
it, so re-adding later mints records that must be published again.
|
|
206
|
+
"""
|
|
207
|
+
response: DomainDeletedV1 = self._client.request(
|
|
208
|
+
method="DELETE", path=f"/api/v1/domains/{encode_path_segment(id)}"
|
|
209
|
+
)
|
|
210
|
+
return response
|