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 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 = "0.2.0"
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,
@@ -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,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. 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.
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