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 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.0.0"
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
- url = self._build_url(path, query)
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.
@@ -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``, and also when a page carries no ``data`` list, so a malformed
29
- response ends the walk instead of looping forever.
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}
@@ -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
@@ -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)