sendly-python 1.0.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 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,9 +41,12 @@ 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
@@ -57,6 +61,7 @@ __all__ = [
57
61
  "AnalyticsResource",
58
62
  "CampaignsResource",
59
63
  "ContactsResource",
64
+ "DeliverabilityResource",
60
65
  "DomainsResource",
61
66
  "EmailsResource",
62
67
  "EventsResource",
@@ -74,9 +79,12 @@ __all__ = [
74
79
  "SendlyRateLimitError",
75
80
  "SendlyServerError",
76
81
  "SendlyValidationError",
82
+ "SnippetsResource",
77
83
  "SuppressionResource",
78
84
  "TemplatesResource",
85
+ "TopicsResource",
79
86
  "UsageResource",
87
+ "ValidationResource",
80
88
  "VerifyResource",
81
89
  "WebhooksResource",
82
90
  "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,9 +44,12 @@ 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
@@ -59,7 +63,7 @@ if TYPE_CHECKING:
59
63
  __all__ = ["DEFAULT_BASE_URL", "SDK_VERSION", "Sendly"]
60
64
 
61
65
  #: Package version. Kept in sync with ``pyproject.toml``.
62
- SDK_VERSION = "1.0.0"
66
+ SDK_VERSION = "1.1.0"
63
67
 
64
68
  #: Default production API base. Override via ``base_url`` for staging/self-hosted.
65
69
  DEFAULT_BASE_URL = "https://api.sendly.now"
@@ -137,6 +141,7 @@ class Sendly:
137
141
  self.webhooks = WebhooksResource(self)
138
142
  self.suppression = SuppressionResource(self)
139
143
  self.lists = ListsResource(self)
144
+ self.snippets = SnippetsResource(self)
140
145
  # Reads only -- the mailbox writes need a user, which an API key is not.
141
146
  self.mailboxes = MailboxesResource(self)
142
147
  # /api/v1 surface. Same client, same auth; bare resource bodies instead
@@ -147,6 +152,9 @@ class Sendly:
147
152
  self.analytics = AnalyticsResource(self)
148
153
  self.usage = UsageResource(self)
149
154
  self.projects = ProjectsResource(self)
155
+ self.topics = TopicsResource(self)
156
+ self.validation = ValidationResource(self)
157
+ self.deliverability = DeliverabilityResource(self)
150
158
 
151
159
  def request(
152
160
  self,
@@ -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)
@@ -5,20 +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,
15
20
  DomainSetupSession,
21
+ DomainV1,
16
22
  DomainVerificationStatus,
23
+ JSONDict,
24
+ Query,
17
25
  )
18
26
 
19
27
 
20
28
  class DomainsResource:
21
- """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
+ """
22
38
 
23
39
  def __init__(self, client: Sendly) -> None:
24
40
  self._client = client
@@ -28,7 +44,12 @@ class DomainsResource:
28
44
 
29
45
  Pass ``region`` to pin this domain to a specific AWS SES region. On the
30
46
  first domain for a project this also locks the project's region;
31
- subsequent calls must match. The response includes DNS records to set.
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.
32
53
  """
33
54
  envelope = self._client.request(method="POST", path="/api/domains", body=body)
34
55
  record: DomainRecord = self._client.unwrap(envelope)
@@ -48,7 +69,14 @@ class DomainsResource:
48
69
  return record
49
70
 
50
71
  def verify(self, id: str) -> DomainVerificationStatus:
51
- """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
+ """
52
80
  envelope = self._client.request(
53
81
  method="POST", path=f"/api/domains/{encode_path_segment(id)}/verify"
54
82
  )
@@ -78,6 +106,105 @@ class DomainsResource:
78
106
  session: DomainSetupSession = self._client.unwrap(envelope)
79
107
  return session
80
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
+
81
131
  def delete(self, id: str) -> None:
82
132
  """Delete a domain."""
83
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
@@ -11,13 +11,13 @@ if TYPE_CHECKING:
11
11
  from sendly.types import (
12
12
  BatchSendResponse,
13
13
  Body,
14
- EmailGetResponse,
14
+ EmailDetailResponse,
15
15
  EmailListResponse,
16
+ EmailResponse,
16
17
  EmailTestV1,
17
18
  EmailV1,
18
19
  Query,
19
20
  SendEmailData,
20
- SuccessEmpty,
21
21
  )
22
22
 
23
23
 
@@ -105,16 +105,28 @@ class EmailsResource:
105
105
  )
106
106
  return response
107
107
 
108
- def get(self, id: str) -> EmailGetResponse:
109
- """Fetch a single email and its delivery events."""
110
- response: EmailGetResponse = self._client.request(
108
+ def get(self, id: str) -> EmailDetailResponse:
109
+ """Fetch a single email together with its DELIVERY history, oldest first.
110
+
111
+ ``events`` here is the delivery timeline behind ``status`` -- not the
112
+ custom events recorded with ``events.record``, which are read from
113
+ ``events.list``. Before 1.1 this operation answered the wrong relation
114
+ and published the message's dedup and idempotency ledger keys with it.
115
+ """
116
+ response: EmailDetailResponse = self._client.request(
111
117
  method="GET", path=f"/api/emails/{encode_path_segment(id)}"
112
118
  )
113
119
  return response
114
120
 
115
- def cancel_schedule(self, id: str) -> SuccessEmpty:
116
- """Cancel a scheduled (PENDING) email before it fires."""
117
- response: SuccessEmpty = self._client.request(
121
+ def cancel_schedule(self, id: str) -> EmailResponse:
122
+ """Cancel a scheduled (PENDING) email before it fires.
123
+
124
+ Answers the email itself, not an empty acknowledgement: the contract has
125
+ always published that shape here, and the caller wants the row's new
126
+ status more than a success flag it already inferred from the absence of
127
+ an exception.
128
+ """
129
+ response: EmailResponse = self._client.request(
118
130
  method="DELETE", path=f"/api/emails/{encode_path_segment(id)}/schedule"
119
131
  )
120
132
  return response
@@ -49,7 +49,9 @@ class EventsResource:
49
49
  def record(self, body: Body) -> EventRecord:
50
50
  """Record a custom event for a contact (``/api/v1``).
51
51
 
52
- Requires ``name``; optionally takes ``contact_id`` and a ``data`` object.
52
+ Requires ``name``; optionally takes ``contact_id`` and a ``payload``
53
+ object. It was ``data`` before 1.1 -- on a wire where every legacy
54
+ envelope has a ``data``, the name said nothing about whose it was.
53
55
  The v1 counterpart of :meth:`track`, returning the created event body
54
56
  rather than a ``{success, data}`` envelope.
55
57