sendly-python 0.2.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,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:
@@ -35,6 +37,8 @@ from sendly.resources.domains import DomainsResource
35
37
  from sendly.resources.emails import EmailsResource
36
38
  from sendly.resources.events import EventsResource
37
39
  from sendly.resources.lists import ListsResource
40
+ from sendly.resources.mailboxes import MailboxesResource
41
+ from sendly.resources.projects import ProjectsResource
38
42
  from sendly.resources.segments import SegmentsResource
39
43
  from sendly.resources.suppression import SuppressionResource
40
44
  from sendly.resources.templates import TemplatesResource
@@ -57,6 +61,8 @@ __all__ = [
57
61
  "EmailsResource",
58
62
  "EventsResource",
59
63
  "ListsResource",
64
+ "MailboxesResource",
65
+ "ProjectsResource",
60
66
  "SegmentsResource",
61
67
  "Sendly",
62
68
  "SendlyAuthenticationError",
sendly/client.py CHANGED
@@ -40,6 +40,8 @@ from sendly.resources.domains import DomainsResource
40
40
  from sendly.resources.emails import EmailsResource
41
41
  from sendly.resources.events import EventsResource
42
42
  from sendly.resources.lists import ListsResource
43
+ from sendly.resources.mailboxes import MailboxesResource
44
+ from sendly.resources.projects import ProjectsResource
43
45
  from sendly.resources.segments import SegmentsResource
44
46
  from sendly.resources.suppression import SuppressionResource
45
47
  from sendly.resources.templates import TemplatesResource
@@ -57,7 +59,7 @@ if TYPE_CHECKING:
57
59
  __all__ = ["DEFAULT_BASE_URL", "SDK_VERSION", "Sendly"]
58
60
 
59
61
  #: Package version. Kept in sync with ``pyproject.toml``.
60
- SDK_VERSION = "0.2.0"
62
+ SDK_VERSION = "1.0.0"
61
63
 
62
64
  #: Default production API base. Override via ``base_url`` for staging/self-hosted.
63
65
  DEFAULT_BASE_URL = "https://api.sendly.now"
@@ -135,6 +137,8 @@ class Sendly:
135
137
  self.webhooks = WebhooksResource(self)
136
138
  self.suppression = SuppressionResource(self)
137
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)
138
142
  # /api/v1 surface. Same client, same auth; bare resource bodies instead
139
143
  # of the legacy {success, data} envelope, and RFC 9457 problem errors.
140
144
  self.campaigns = CampaignsResource(self)
@@ -142,6 +146,7 @@ class Sendly:
142
146
  self.workflows = WorkflowsResource(self)
143
147
  self.analytics = AnalyticsResource(self)
144
148
  self.usage = UsageResource(self)
149
+ self.projects = ProjectsResource(self)
145
150
 
146
151
  def request(
147
152
  self,
@@ -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)}")
@@ -13,6 +13,8 @@ if TYPE_CHECKING:
13
13
  Body,
14
14
  EmailGetResponse,
15
15
  EmailListResponse,
16
+ EmailTestV1,
17
+ EmailV1,
16
18
  Query,
17
19
  SendEmailData,
18
20
  SuccessEmpty,
@@ -25,12 +27,42 @@ class EmailsResource:
25
27
  def __init__(self, client: Sendly) -> None:
26
28
  self._client = client
27
29
 
28
- def send(
30
+ def send(self, body: Body, *, idempotency_key: str | None = None) -> EmailV1:
31
+ """Send one transactional email.
32
+
33
+ Posts to the versioned ``POST /api/v1/emails`` and returns the bare
34
+ receipt it answers 202 with: ``{id, status, to, from}``. ``status`` is a
35
+ real delivery state -- poll ``emails.get(id)`` for the events behind
36
+ it. Takes a single recipient; use ``cc``/``bcc`` to copy others.
37
+
38
+ Pass ``idempotency_key`` (1-255 chars) to dedupe replays for 24h.
39
+
40
+ Before 1.0 this posted to the legacy ``POST /api/emails``, which
41
+ answered with row ids and no delivery status and fanned an array
42
+ ``to`` out to several recipients. That behaviour is
43
+ :meth:`send_legacy`, unchanged.
44
+ """
45
+ response: EmailV1 = self._client.request(
46
+ method="POST",
47
+ path="/api/v1/emails",
48
+ body=body,
49
+ headers=idempotency_headers(idempotency_key),
50
+ )
51
+ return response
52
+
53
+ def send_legacy(
29
54
  self, body: Body, *, idempotency_key: str | None = None
30
55
  ) -> SendEmailData | list[SendEmailData]:
31
- """Send a single transactional email.
56
+ """The pre-1.0 :meth:`send`: the legacy ``POST /api/emails``.
32
57
 
33
- Pass ``idempotency_key`` (1-255 chars) to dedupe replays for 24h.
58
+ Returns the envelope's ``data``, ``{emails, timestamp}``, where
59
+ ``emails`` has one entry per recipient (an array ``to`` fans out to
60
+ several). Each entry is ``{contact: {id, email}, email}`` -- ``email``
61
+ being the id of the queued email record for that recipient. Reports no
62
+ delivery status of its own.
63
+
64
+ Kept as the escape hatch for a caller that depends on the fan-out or on
65
+ the envelope shape. New code should use :meth:`send`.
34
66
  """
35
67
  envelope = self._client.request(
36
68
  method="POST",
@@ -41,6 +73,21 @@ class EmailsResource:
41
73
  data: SendEmailData | list[SendEmailData] = self._client.unwrap(envelope)
42
74
  return data
43
75
 
76
+ def send_test(self, body: Body) -> EmailTestV1:
77
+ """Send a test email from the project's sandbox address.
78
+
79
+ Goes nowhere real: the sandbox address is the SENDER, resolved
80
+ server-side (naming a ``from`` is refused), and the mail lands in the
81
+ project owner's own verified inbox. This exercises rendering and the
82
+ send path without touching a live recipient or a sending reputation.
83
+ Read ``projects.get()["sandbox_address"]`` to know what it sends from
84
+ -- the response's ``sandbox: true`` says only that it was one.
85
+ """
86
+ response: EmailTestV1 = self._client.request(
87
+ method="POST", path="/api/v1/emails/test", body=body
88
+ )
89
+ return response
90
+
44
91
  def batch(self, body: Body, *, idempotency_key: str | None = None) -> BatchSendResponse:
45
92
  """Send a batch (up to 100) of transactional emails in one call."""
46
93
  response: BatchSendResponse = self._client.request(
@@ -0,0 +1,75 @@
1
+ """Mailboxes resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from sendly.resources._helpers import encode_path_segment
8
+
9
+ if TYPE_CHECKING:
10
+ from sendly.client import Sendly
11
+ from sendly.types import AppPasswordList, MailboxDetail, MailboxList
12
+
13
+
14
+ class MailboxesResource:
15
+ """Receiving mailboxes on the project's verified domains.
16
+
17
+ Read only, and deliberately so. Creating or deleting a mailbox, and minting
18
+ or revoking an app password, all resolve the acting project admin from the
19
+ session user. An API-key context carries no user, so those routes answer
20
+ 401 to any key however broad its scopes -- the contract records this by
21
+ publishing ``SessionAuth`` without ``ApiKeyAuth`` on them. This SDK
22
+ authenticates only with API keys, so such methods could never succeed; they
23
+ are listed in ``tests/test_contract.py``'s ``NOT_SDK_CALLABLE`` instead.
24
+
25
+ The three reads below are the opposite case: their membership check is
26
+ conditional, so a key really can call them.
27
+ """
28
+
29
+ def __init__(self, client: Sendly) -> None:
30
+ self._client = client
31
+
32
+ def list(self) -> MailboxList:
33
+ """Every mailbox on the project's domains, newest first.
34
+
35
+ Not paginated. A project is capped at 10 mailboxes, but the cap counts
36
+ only those holding (or mid-way to holding) a real account --
37
+ ``PROVISIONING``, ``ACTIVE`` and ``SUSPENDED``. ``FAILED`` rows are
38
+ excluded from it deliberately, so that a Stalwart outage cannot spend a
39
+ project's whole allowance, and they are still returned here: a project
40
+ with a run of failed provisions can therefore list more than 10.
41
+
42
+ This lists the mailboxes themselves, never their contents: received
43
+ messages are not part of the public API.
44
+ """
45
+ envelope = self._client.request(method="GET", path="/api/mailboxes")
46
+ records: MailboxList = self._client.unwrap(envelope)
47
+ return records
48
+
49
+ def get(self, id: str) -> MailboxDetail:
50
+ """One mailbox, with the IMAP/SMTP host, port and username to connect with.
51
+
52
+ The password is not included and is never returned here -- mailbox
53
+ credentials are app passwords, created from the dashboard and shown once.
54
+ """
55
+ envelope = self._client.request(
56
+ method="GET", path=f"/api/mailboxes/{encode_path_segment(id)}"
57
+ )
58
+ detail: MailboxDetail = self._client.unwrap(envelope)
59
+ return detail
60
+
61
+ def list_app_passwords(self, id: str) -> AppPasswordList:
62
+ """The app passwords still active on a mailbox -- metadata only.
63
+
64
+ Revoked ones are not returned: the route filters on ``revokedAt: null``,
65
+ so this is the set that can currently authenticate, not an audit
66
+ history.
67
+
68
+ ``lastFour`` is the only fragment of the secret that survives creation,
69
+ so this identifies a credential without being able to reconstruct it.
70
+ """
71
+ envelope = self._client.request(
72
+ method="GET", path=f"/api/mailboxes/{encode_path_segment(id)}/app-passwords"
73
+ )
74
+ records: AppPasswordList = self._client.unwrap(envelope)
75
+ return records
@@ -0,0 +1,33 @@
1
+ """Projects 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 ProjectRecordV1
10
+
11
+
12
+ class ProjectsResource:
13
+ """The project the credential resolves to.
14
+
15
+ There is no ``create`` here. Creating a project resolves the owner from the
16
+ session user and refuses an API key with 401, so it is recorded in
17
+ ``tests/test_contract.py``'s ``NOT_SDK_CALLABLE`` rather than shipped as a
18
+ method that cannot work.
19
+ """
20
+
21
+ def __init__(self, client: Sendly) -> None:
22
+ self._client = client
23
+
24
+ def get(self) -> ProjectRecordV1:
25
+ """Read the current project.
26
+
27
+ Takes no id: the project is whichever one the API key belongs to.
28
+ Carries ``sandbox_address``, which is where a test send arrives --
29
+ without it a test send is undiscoverable, since the caller cannot say
30
+ where to look for it.
31
+ """
32
+ response: ProjectRecordV1 = self._client.request(method="GET", path="/api/v1/projects")
33
+ return response
sendly/types.py CHANGED
@@ -42,6 +42,11 @@ EmailRecord = JSONDict
42
42
  EmailListResponse = JSONDict
43
43
  EmailGetResponse = JSONDict
44
44
 
45
+ # The versioned send. Distinct from the legacy aliases above, which post to
46
+ # ``/api/emails`` and answer with row ids and no delivery status.
47
+ EmailV1 = JSONDict
48
+ EmailTestV1 = JSONDict
49
+
45
50
  # ---------- Contacts ----------
46
51
 
47
52
  ContactRecord = JSONDict
@@ -52,6 +57,25 @@ ContactListResponse = JSONDict
52
57
  DomainRecord = JSONDict
53
58
  DomainListResponse = JSONDict
54
59
  DomainVerificationStatus = JSONDict
60
+ #: ``{token, connectUrl, expiresAt}`` -- the link a person opens to finish setup.
61
+ DomainSetupSession = JSONDict
62
+
63
+ # ---------- Mailboxes ----------
64
+
65
+ MailboxRecord = JSONDict
66
+ #: A mailbox plus the IMAP/SMTP host, port and username a mail client needs.
67
+ MailboxDetail = JSONDict
68
+ AppPasswordRecord = JSONDict
69
+ #: The list aliases are not decoration: inside ``MailboxesResource`` the name
70
+ #: ``list`` is the resource's own method, so a bare ``list[MailboxRecord]``
71
+ #: annotation resolves to that method and fails type checking. Naming the list
72
+ #: types here sidesteps the shadowing and keeps the annotations readable.
73
+ MailboxList = list[JSONDict]
74
+ AppPasswordList = list[JSONDict]
75
+
76
+ # ---------- Projects (v1) ----------
77
+
78
+ ProjectRecordV1 = JSONDict
55
79
 
56
80
  # ---------- Templates ----------
57
81
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sendly-python
3
- Version: 0.2.0
3
+ Version: 1.0.0
4
4
  Summary: Official Sendly Python SDK
5
5
  Project-URL: Homepage, https://sendly.now
6
6
  Project-URL: Documentation, https://docs.sendly.now
@@ -33,8 +33,8 @@ Description-Content-Type: text/markdown
33
33
  # Sendly Python SDK
34
34
 
35
35
  Official Python SDK for the [Sendly](https://sendly.now) REST API — transactional
36
- email, contacts, events, domains, templates, email verification, webhooks, and
37
- suppression.
36
+ email, contacts, events, domains, templates, email verification, webhooks,
37
+ suppression, and mailbox and project reads.
38
38
 
39
39
  [![CI](https://github.com/DevinoSolutions/sendly-python/actions/workflows/ci.yml/badge.svg)](https://github.com/DevinoSolutions/sendly-python/actions/workflows/ci.yml)
40
40
 
@@ -90,7 +90,7 @@ from sendly import Sendly
90
90
 
91
91
  sendly = Sendly() # reads SENDLY_API_KEY
92
92
 
93
- result = sendly.emails.send(
93
+ receipt = sendly.emails.send(
94
94
  {
95
95
  "from": "hello@yourdomain.com",
96
96
  "to": "customer@example.com",
@@ -98,9 +98,35 @@ result = sendly.emails.send(
98
98
  "body": "<h1>Thanks for signing up!</h1>",
99
99
  }
100
100
  )
101
- print(result["id"])
101
+
102
+ # `status` is a real delivery state; poll `emails.get(receipt["id"])` for the
103
+ # events behind it.
104
+ print(receipt["id"], receipt["status"])
102
105
  ```
103
106
 
107
+ ### Upgrading from 0.x
108
+
109
+ **1.0 repoints `emails.send` to the versioned `POST /api/v1/emails`.** It now
110
+ takes one recipient (`cc`/`bcc` copy others) and returns the `202` receipt
111
+ `{id, status, to, from}`, where `status` is a real delivery state. Before 1.0 it
112
+ posted to the legacy `POST /api/emails`, fanned an array `to` out to several
113
+ recipients, and returned `{emails, timestamp}` with no delivery status.
114
+
115
+ The old behaviour is kept, unchanged, as `emails.send_legacy`. Two ways to
116
+ upgrade:
117
+
118
+ - **Keep the old shapes:** rename the call. `send(...)` → `send_legacy(...)`.
119
+ Done.
120
+ - **Take the new default:** read the receipt instead of the envelope
121
+ (`receipt["id"]` / `receipt["status"]` in place of
122
+ `result["emails"][0]["email"]`), send to one recipient per call, and note that
123
+ failures now carry the v1 error fields (`err.error_code` is lowercase,
124
+ `err.request_id` and `err.field_errors` are set) — the exception classes are
125
+ the same, so `except` blocks stand.
126
+
127
+ Nothing else changed shape. See [CHANGELOG.md](./CHANGELOG.md) for the full
128
+ 1.0.0 entry.
129
+
104
130
  Or pass the key explicitly:
105
131
 
106
132
  ```python
@@ -134,9 +160,15 @@ with Sendly() as sendly:
134
160
  ### Emails
135
161
 
136
162
  ```python
137
- # Single send (pass idempotency_key to dedupe replays for 24h)
138
- sendly.emails.send({"from": "a@you.com", "to": "b@them.com", "subject": "Hi", "body": "<p>Hi</p>"},
139
- idempotency_key="order-42-receipt")
163
+ # Single send on /api/v1 (pass idempotency_key to dedupe replays for 24h).
164
+ # One recipient in `to`; `cc` / `bcc` copy others. Returns {id, status, to, from}.
165
+ receipt = sendly.emails.send({"from": "a@you.com", "to": "b@them.com", "subject": "Hi", "body": "<p>Hi</p>"},
166
+ idempotency_key="order-42-receipt")
167
+
168
+ # The pre-1.0 send: legacy /api/emails, an array `to` fans out, answers
169
+ # {emails, timestamp} with no delivery status.
170
+ sendly.emails.send_legacy({"from": "a@you.com", "to": ["b@them.com", "c@them.com"],
171
+ "subject": "Hi", "body": "<p>Hi</p>"})
140
172
 
141
173
  # Batch send (up to 100)
142
174
  sendly.emails.batch({"emails": [{"from": "a@you.com", "to": "b@them.com", "subject": "Hi", "body": "<p>Hi</p>"}]})
@@ -180,9 +212,45 @@ sendly.domains.list()
180
212
  sendly.domains.get("d_123")
181
213
  sendly.domains.verify("d_123")
182
214
  sendly.domains.get_verification("d_123")
215
+ sendly.domains.start_setup("d_123") # -> {"token", "connectUrl", "expiresAt"}
183
216
  sendly.domains.delete("d_123")
184
217
  ```
185
218
 
219
+ `start_setup` returns the hand-off as the API returns it. Open `connectUrl` in a
220
+ browser to finish DNS setup at the registrar.
221
+
222
+ ### Mailboxes
223
+
224
+ Reads only — see [What the SDK does not expose](#what-the-sdk-does-not-expose).
225
+
226
+ ```python
227
+ sendly.mailboxes.list() # -> [mailbox, ...], not paginated
228
+ detail = sendly.mailboxes.get("mb_123")
229
+
230
+ # `settings` carries the IMAP and SMTP host, port, security and username.
231
+ print(detail["settings"]["imap"]["host"], detail["settings"]["imap"]["port"])
232
+
233
+ # Metadata only — `lastFour` is the one fragment of the secret that survives
234
+ # creation, so a credential can be identified but not rebuilt.
235
+ for pw in sendly.mailboxes.list_app_passwords("mb_123"):
236
+ print(pw["name"], pw["lastFour"], pw["lastUsedAt"])
237
+ ```
238
+
239
+ This lists the mailboxes themselves, never their contents — received messages
240
+ are not part of the public API. The mailbox **password** is never returned by
241
+ any of these reads; mailbox credentials are app passwords, created from the
242
+ dashboard and shown once. `list_app_passwords` returns only the passwords that
243
+ are still active — a revoked one drops out, so this is not an audit history.
244
+
245
+ **The per-project cap is 10 mailboxes.** It counts only those holding, or
246
+ mid-way to holding, a real account — `PROVISIONING`, `ACTIVE` and `SUSPENDED`.
247
+ `FAILED` rows are excluded on purpose, so that a burst of failed provisions
248
+ cannot eat a project's allowance and turn an outage into "you have reached your
249
+ mailbox limit"; they are still returned by `list()`, so a project that has had
250
+ failures can list more than 10. Exceeding the cap is a `409`
251
+ (`SendlyConflictError`) from whatever creates the mailbox — which is not this
252
+ SDK, since mailbox creation needs a signed-in user.
253
+
186
254
  ### Templates
187
255
 
188
256
  ```python
@@ -316,7 +384,7 @@ Available on the six cursor-paginated listings: `campaigns.iter_list`,
316
384
  `events.list_names` / `events.stats` return a bounded aggregate rather than a
317
385
  cursor, so they have no iterator.
318
386
 
319
- ### Segments, workflows, events, analytics, usage
387
+ ### Segments, workflows, events, analytics, usage, projects
320
388
 
321
389
  ```python
322
390
  segment = sendly.segments.create({"name": "Power users", "type": "DYNAMIC",
@@ -343,8 +411,71 @@ sendly.analytics.top_campaigns({"limit": 5})
343
411
 
344
412
  usage = sendly.usage.get()
345
413
  print(usage["plan"], usage["monthly"])
414
+
415
+ project = sendly.projects.get()
416
+ print(project["sandbox_address"])
417
+ ```
418
+
419
+ ### Emails: `send` vs `send_legacy`
420
+
421
+ The same split as `events.track` / `events.record`, resolved the other way
422
+ round: since 1.0, `emails.send` IS the versioned send. It posts to
423
+ `/api/v1/emails` and answers `202` with `{id, status, to, from}`, where `status`
424
+ is a real delivery state you can poll on. It takes one recipient — use
425
+ `cc`/`bcc` to copy others — instead of fanning an array out.
426
+ `emails.send_legacy` is the pre-1.0 send on `POST /api/emails`, unchanged: row
427
+ ids, **no delivery status**, array `to` fanned out. See
428
+ [Upgrading from 0.x](#upgrading-from-0x).
429
+
430
+ ```python
431
+ receipt = sendly.emails.send(
432
+ {"to": "user@example.com", "subject": "hi", "body": "<p>hi</p>"},
433
+ idempotency_key="order-42",
434
+ )
435
+ print(receipt["status"])
346
436
  ```
347
437
 
438
+ ### Test sends
439
+
440
+ `emails.send_test` proves the send path works without touching a live
441
+ recipient. Two things about it are easy to get backwards:
442
+
443
+ - **The sandbox address is the *sender*, not the destination.** It is resolved
444
+ server-side, and naming a `from` yourself is **refused** rather than ignored —
445
+ so a request expecting a different sender never gets a success it would
446
+ misread. `projects.get()["sandbox_address"]` tells you what it sends *from*;
447
+ the response's `from` says the same thing.
448
+ - **It lands in the project owner's own inbox.** `to` is optional and defaults
449
+ to the project owner's verified account email, which is the only address a
450
+ sandbox send may reach — any other value is refused.
451
+
452
+ ```python
453
+ test = sendly.emails.send_test({"subject": "hi", "body": "<p>hi</p>"})
454
+ print(test["to"], test["from"], test["sandbox"]) # sandbox is always True here
455
+ ```
456
+
457
+ Everything else applies unchanged: the same rendering, the same content scan,
458
+ and the same daily and trust-tier caps as a real send. It takes no
459
+ `idempotency_key` — the recipient is the caller's own inbox, a daily cap already
460
+ bounds it, and "send me another one" is the normal second call rather than a
461
+ mistake worth deduplicating.
462
+
463
+ ### What the SDK does not expose
464
+
465
+ An API key resolves no user, and a handful of routes resolve the acting project
466
+ admin from the session before reading any scope — so they answer `401` to any
467
+ key, however broad its scopes. The contract states this: those operations publish
468
+ `SessionAuth` without `ApiKeyAuth`.
469
+
470
+ Rather than ship methods that could never succeed, they are listed in
471
+ `tests/test_contract.py`'s `NOT_SDK_CALLABLE` and checked against the spec's own
472
+ declarations, in both directions. They are: creating and deleting a mailbox,
473
+ creating and revoking an app password, all four API-key operations, and creating
474
+ a project. Use the dashboard or an OAuth connection for those.
475
+
476
+ Mailbox **reads** are exposed — their membership check is conditional, so a key
477
+ really can call them.
478
+
348
479
  ## Error handling
349
480
 
350
481
  Every non-2xx response raises a `SendlyError` subclass carrying `status_code`,
@@ -456,7 +587,7 @@ comparison and reject a stale or non-numeric timestamp.
456
587
 
457
588
  ## Async
458
589
 
459
- Only a synchronous client ships in v0.1. An `httpx.AsyncClient`-backed async
590
+ Only a synchronous client ships today. An `httpx.AsyncClient`-backed async
460
591
  variant is planned.
461
592
 
462
593
  ## Development
@@ -474,6 +605,45 @@ pytest
474
605
 
475
606
  Tests are fully hermetic (httpx `MockTransport`) and hit no network.
476
607
 
608
+ ### Refreshing the vendored OpenAPI spec
609
+
610
+ `tests/fixtures/openapi.json` is a committed snapshot of Sendly's OpenAPI
611
+ contract; the contract suite (`tests/test_contract.py`) verifies the SDK surface
612
+ against it and never touches the network.
613
+
614
+ `scripts/sync_spec.py` requires `SENDLY_OPENAPI_URL`. There is **no default**,
615
+ and in particular it does not default to production:
616
+
617
+ ```bash
618
+ SENDLY_OPENAPI_URL=/path/to/sendly/apps/web/openapi/openapi.json \
619
+ python scripts/sync_spec.py
620
+
621
+ SENDLY_OPENAPI_URL=... python scripts/sync_spec.py --check # is the copy stale?
622
+ ```
623
+
624
+ `SENDLY_OPENAPI_URL` accepts a filesystem path (the normal case — the committed
625
+ contract in the Sendly platform monorepo at `apps/web/openapi/openapi.json`) or
626
+ an `http(s)://` URL of a local or staging API. Running the script with it unset
627
+ exits non-zero and prints what to set.
628
+
629
+ **Do not point it at `https://api.sendly.now`.** Vendoring the spec from the
630
+ deployed API makes the SDK mirror what is *running* rather than what the repo
631
+ *declares*, so any drift between the platform's code and its committed contract
632
+ is laundered into "correct" on the way in — the SDK re-vendors to match the
633
+ deployment and the mismatch vanishes silently. That destroys the vendored spec's
634
+ only job: it is the fixed reference `tests/test_contract.py` compares against, so
635
+ an SDK synced from production can no longer detect the very drift it exists to
636
+ catch. It is also unreproducible and unreviewable.
637
+
638
+ This is not hard-blocked — "what does production actually serve?" is a legitimate
639
+ one-off. Doing it prints an unmissable warning (and a CI annotation), because
640
+ *quiet* is what made the old default dangerous, not the host. Never commit the
641
+ result, and never wire that host into CI or any unattended job.
642
+
643
+ `--check` is the exception to the fail-loud rule: it never runs unattended
644
+ against an unknown source, so with `SENDLY_OPENAPI_URL` unset it skips with a
645
+ notice and exits 0, keeping CI and fork pull requests green.
646
+
477
647
  ## Documentation
478
648
 
479
649
  Full API reference: <https://docs.sendly.now>
@@ -1,8 +1,8 @@
1
- sendly/__init__.py,sha256=AQ1Yu-7DFJ9LQz_-w6RSk_u25U-oITJGwRwJ1M9v-9s,2487
2
- sendly/client.py,sha256=GRII4hPJwBNPDPL9i84y3YwtPvRgT5CVXGkBgL1r7IY,11356
1
+ sendly/__init__.py,sha256=BVroJYUG_CgQ3NB1fpDsUQsRP9i37q2bwYgv0jsYM2E,2755
2
+ sendly/client.py,sha256=zrN7isw153Adh2GPKx3CAA6AMkXhlai5p5i4uD3FO0I,11645
3
3
  sendly/errors.py,sha256=urF33X-d39OEiasLh6C7PlrrUmmrS3XIMvFYj20rUy8,7002
4
4
  sendly/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
- sendly/types.py,sha256=ggxBcjjkdgmJEPNk04tFdrLoTggOFZuj2kcqsvBPIXk,3140
5
+ sendly/types.py,sha256=mnuTukJ6O31qCeD46EHzgATY7BQK2PX_ZMNPFGrQkP8,4065
6
6
  sendly/webhook_utils.py,sha256=epmK860gLknaElxfXdVD3h_9ASkjWQsyl1exUC8qBL4,4762
7
7
  sendly/resources/__init__.py,sha256=Nf7zjcFjsztXsGtrvxq3KGacX7SwDgSf3r0nAVHLvPY,73
8
8
  sendly/resources/_helpers.py,sha256=W6AlzXuqH53Vk1dIxuQm_-Vpq9gmZvLzKPRionjH9wQ,550
@@ -10,10 +10,12 @@ sendly/resources/_pagination.py,sha256=dp0rNKruPBtCgPZT8ZPzmGGVpbFN2SCQdV4m9-qlL
10
10
  sendly/resources/analytics.py,sha256=7w4A-w-dXZkgLi2j1dO6X88V-T9itlF0rWjdLiFAStc,1770
11
11
  sendly/resources/campaigns.py,sha256=vcpwNa51ncSuieGYUhwcMLpzB-cRrf6qZ1RXB0iVmo8,5293
12
12
  sendly/resources/contacts.py,sha256=7DwbGXvwUw8Tf5WfIUU0ZEvJs3ZBIqulkbN_Ke0-ilc,3274
13
- sendly/resources/domains.py,sha256=m69uHKGhmtVlzo3QAmMmMHvmpc0T3JAv5izTWxDpxhc,2368
14
- sendly/resources/emails.py,sha256=3LHxre7lJfa_6z1F9Rzmdh5QDwhYpwTWaAJZGVCLsY8,2390
13
+ sendly/resources/domains.py,sha256=2vf5hibDvL_1nCIrS17NWXFRpinTroRf4xbFMHYnx6E,3143
14
+ sendly/resources/emails.py,sha256=KDipwAqMJVWl52w8JjbHrLL4AFCgphOoZdG_tHrtufo,4599
15
15
  sendly/resources/events.py,sha256=8aFUPa1iTVqEUnhwnpcsfE7_JyirtBw7POgadk4hhMs,3926
16
16
  sendly/resources/lists.py,sha256=KXHOW_UQMEdXoat9mHJ4mJKmvrzk0AVQ3k8273GRIH4,2189
17
+ sendly/resources/mailboxes.py,sha256=tlXyrnsx6xpyAb3oyt2qbpK1Ri5nE4kG0AQMia4loHE,3200
18
+ sendly/resources/projects.py,sha256=g694LMqh7cI2qCD1nZ6QQEfOvT42A8EBltbEJawc2P8,1096
17
19
  sendly/resources/segments.py,sha256=dc4KzorLRSfkZe63J144wm6W5bjVaDA4U8ID7HyyK_U,3944
18
20
  sendly/resources/suppression.py,sha256=NNIhmkq1bC2qe1i4nSGZDCvuGBT7JddIrkXlVP1qASU,1716
19
21
  sendly/resources/templates.py,sha256=DhewoFRqxGfjo0brAtG9rI6GHTCWNv4J01TylB4luXI,2104
@@ -21,7 +23,7 @@ sendly/resources/usage.py,sha256=kMcoAeGAnCilLyNihKBFcfFf-CVoSlpKUmTtlfDWAMA,879
21
23
  sendly/resources/verify.py,sha256=QyVNmrzZ4Nj87YWxMPt9wlovcCyZutV9eGmn49c0EEg,818
22
24
  sendly/resources/webhooks.py,sha256=rooES4-HMy7mhC9D1bzVDUpJ34cf1yZkVilkoWEIBEQ,2763
23
25
  sendly/resources/workflows.py,sha256=42j-227rFUkWjA6DYmKYxqc6Bfdpxy5IHDuDgZrUO5k,5381
24
- sendly_python-0.2.0.dist-info/METADATA,sha256=qP01SKUw9EeFuqAMVvnnyPTMLrgs-nwZZJlU9lbAn2w,16456
25
- sendly_python-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
26
- sendly_python-0.2.0.dist-info/licenses/LICENSE,sha256=TZHT_f_vV-xujWjSy7EkTybNT6iivrfGHLd-rR1Xl7c,1073
27
- sendly_python-0.2.0.dist-info/RECORD,,
26
+ sendly_python-1.0.0.dist-info/METADATA,sha256=Nlcp2a2JuMjgQAOjCpvOLWpshDXBZdfwQEgxiwN4DfI,24681
27
+ sendly_python-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
28
+ sendly_python-1.0.0.dist-info/licenses/LICENSE,sha256=TZHT_f_vV-xujWjSy7EkTybNT6iivrfGHLd-rR1Xl7c,1073
29
+ sendly_python-1.0.0.dist-info/RECORD,,