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 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 SendlyConnectionError, SendlyError, error_from_response
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 = "0.1.0"
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
- (``emails``, ``contacts``, ``events``, ``domains``, ``templates``,
61
- ``verify``, ``webhooks``, ``suppression``) for all calls.
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(response.status_code, parsed)
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, or a
19
- synthesized ``http_<status>`` / ``invalid_response`` / ``connection_error``.
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__(self, status_code: int, error_code: str, message: str, body: Any = None) -> None:
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 error_from_response(
73
- status_code: int, error_code: str, message: str, body: Any = None
74
- ) -> SendlyError:
75
- """Map an HTTP status + error envelope to the appropriate error subclass."""
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(status_code, error_code, message, body)
110
+ return SendlyAuthenticationError
80
111
  if status_code == 403:
81
- return SendlyPermissionError(status_code, error_code, message, body)
112
+ return SendlyPermissionError
82
113
  if status_code == 404:
83
- return SendlyNotFoundError(status_code, error_code, message, body)
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(status_code, error_code, message, body)
116
+ return SendlyConflictError
88
117
  if status_code == 429:
89
- return SendlyRateLimitError(status_code, error_code, message, body)
118
+ return SendlyRateLimitError
90
119
  if status_code >= 500:
91
- return SendlyServerError(status_code, error_code, message, body)
92
- return SendlyError(status_code, error_code, message, body)
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
@@ -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)}")