poststack 0.5.0__tar.gz → 0.9.0__tar.gz

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.
Files changed (35) hide show
  1. {poststack-0.5.0 → poststack-0.9.0}/PKG-INFO +30 -16
  2. {poststack-0.5.0 → poststack-0.9.0}/README.md +28 -14
  3. {poststack-0.5.0 → poststack-0.9.0}/pyproject.toml +1 -1
  4. {poststack-0.5.0 → poststack-0.9.0}/src/poststack/__init__.py +22 -3
  5. {poststack-0.5.0 → poststack-0.9.0}/src/poststack/_client.py +165 -16
  6. {poststack-0.5.0 → poststack-0.9.0}/src/poststack/_errors.py +8 -1
  7. {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/__init__.py +9 -0
  8. {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/api_keys.py +26 -0
  9. {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/broadcasts.py +134 -0
  10. {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/contact_properties.py +16 -0
  11. {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/contacts.py +48 -7
  12. {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/domains.py +119 -2
  13. {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/emails.py +46 -0
  14. poststack-0.9.0/src/poststack/resources/inbound_emails.py +147 -0
  15. poststack-0.9.0/src/poststack/resources/mailboxes.py +345 -0
  16. poststack-0.9.0/src/poststack/resources/notification_channels.py +179 -0
  17. {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/segments.py +72 -0
  18. poststack-0.9.0/src/poststack/resources/subscription_topics.py +184 -0
  19. {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/templates.py +38 -0
  20. poststack-0.9.0/src/poststack/resources/webhooks.py +303 -0
  21. {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/workflows.py +84 -51
  22. {poststack-0.5.0 → poststack-0.9.0}/src/poststack/types.py +159 -22
  23. poststack-0.9.0/tests/test_api_parity.py +491 -0
  24. {poststack-0.5.0 → poststack-0.9.0}/tests/test_client.py +119 -13
  25. poststack-0.9.0/tests/test_webhooks_verify.py +60 -0
  26. poststack-0.5.0/src/poststack/resources/mailboxes.py +0 -157
  27. poststack-0.5.0/src/poststack/resources/subscription_topics.py +0 -119
  28. poststack-0.5.0/src/poststack/resources/webhooks.py +0 -193
  29. {poststack-0.5.0 → poststack-0.9.0}/.gitignore +0 -0
  30. {poststack-0.5.0 → poststack-0.9.0}/LICENSE +0 -0
  31. {poststack-0.5.0 → poststack-0.9.0}/src/poststack/py.typed +0 -0
  32. {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/email_validations.py +0 -0
  33. {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/signup_forms.py +0 -0
  34. {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/suppressions.py +0 -0
  35. {poststack-0.5.0 → poststack-0.9.0}/tests/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: poststack
3
- Version: 0.5.0
3
+ Version: 0.9.0
4
4
  Summary: Official Python SDK for the PostStack Email API
5
5
  Project-URL: Homepage, https://poststack.dev
6
6
  Project-URL: Documentation, https://poststack.dev/docs
@@ -105,16 +105,28 @@ client = PostStack(api_key="sk_live_...")
105
105
  try:
106
106
  client.emails.send({"from": "x@y.com", "to": ["z@w.com"], "subject": "hi"})
107
107
  except PostStackError as e:
108
- print(e.status_code, e.error, e.request_id)
108
+ print(e.status_code, e.error, e.code, e.request_id)
109
+ # e.headers holds the response headers; e.retry_after the Retry-After
110
+ # delay in seconds, when the API sent one.
109
111
  ```
110
112
 
113
+ ## Retries
114
+
115
+ Network errors, timeouts, 408, 429 and 5xx responses are retried up to
116
+ `max_retries` times (default 3) with full-jitter exponential backoff. A 429
117
+ that carries `Retry-After` waits exactly that long (up to 60 seconds; a longer
118
+ wait is raised instead). A 429 that is a quota rather than throttling — the
119
+ monthly plan limit or the daily send cap, which carry an error code and no
120
+ `Retry-After` — is raised immediately, since retrying cannot lift it.
121
+
111
122
  ## Configuration
112
123
 
113
124
  ```python
114
125
  client = PostStack(
115
126
  api_key="sk_live_...",
116
127
  base_url="https://api.poststack.dev", # default
117
- timeout=30.0, # default (seconds)
128
+ timeout=30.0, # default (seconds), per attempt
129
+ max_retries=3, # default; 0 disables retrying
118
130
  )
119
131
  ```
120
132
 
@@ -126,19 +138,21 @@ client.emails.send({...}, timeout=60.0)
126
138
 
127
139
  ## Resources
128
140
 
129
- - `client.emails` — send, get, list, cancel, reschedule, batch, events, insights
130
- - `client.domains` — create, list, get, verify, update, delete, DMARC
131
- - `client.contacts` — CRUD, import, export, unsubscribe
132
- - `client.contact_properties` — custom properties
133
- - `client.segments` — create, list, get, update, delete, contacts, preview
134
- - `client.templates` — CRUD, publish, duplicate, presets
135
- - `client.webhooks` — CRUD, test, deliveries, replay
136
- - `client.broadcasts` — CRUD, send, test, variants
141
+ - `client.emails` — send, get, list, cancel, reschedule, batch, events, insights, preview, spam_preview
142
+ - `client.domains` — create, list, get, verify, update, delete, DMARC, DKIM rotation, inbox placement
143
+ - `client.contacts` — CRUD, import, export_csv (text; optional `segment_id`), unsubscribe
144
+ - `client.contact_properties` — custom properties, option_usage
145
+ - `client.segments` — create, list, get, update, delete, contacts, preview, members, growth, broadcasts
146
+ - `client.templates` — CRUD, publish, duplicate, render, presets
147
+ - `client.webhooks` — CRUD, test, deliveries, replay, batch_replay, rotate_secret
148
+ - `client.broadcasts` — CRUD, send, test, variants, resend, performance, non_openers, non_clickers, end_ab_test
137
149
  - `client.suppressions` — list, add, remove
138
- - `client.api_keys` — CRUD
139
- - `client.mailboxes` — CRUD, aliases, password
140
- - `client.subscription_topics` — topics + contact subscriptions
141
- - `client.workflows` — CRUD, steps, activate, pause, trigger
150
+ - `client.api_keys` — CRUD, rotate
151
+ - `client.mailboxes` — CRUD, aliases, password, filters, signature, shares, unread_summary
152
+ - `client.inbound_emails` — list, get, attachments, download, draft_reply, reply, forward
153
+ - `client.subscription_topics` — topics (get, update, list_subscribers) + contact subscriptions
154
+ - `client.workflows` — CRUD, graph (get_graph/put_graph/validate_graph), activate, pause, trigger, post_event
155
+ - `client.notification_channels` — Slack/Discord/Telegram alert channels: CRUD, test, deliveries, replay
142
156
  - `client.signup_forms` — CRUD, submit
143
157
  - `client.email_validations` — validate, validate_batch
144
158
 
@@ -74,16 +74,28 @@ client = PostStack(api_key="sk_live_...")
74
74
  try:
75
75
  client.emails.send({"from": "x@y.com", "to": ["z@w.com"], "subject": "hi"})
76
76
  except PostStackError as e:
77
- print(e.status_code, e.error, e.request_id)
77
+ print(e.status_code, e.error, e.code, e.request_id)
78
+ # e.headers holds the response headers; e.retry_after the Retry-After
79
+ # delay in seconds, when the API sent one.
78
80
  ```
79
81
 
82
+ ## Retries
83
+
84
+ Network errors, timeouts, 408, 429 and 5xx responses are retried up to
85
+ `max_retries` times (default 3) with full-jitter exponential backoff. A 429
86
+ that carries `Retry-After` waits exactly that long (up to 60 seconds; a longer
87
+ wait is raised instead). A 429 that is a quota rather than throttling — the
88
+ monthly plan limit or the daily send cap, which carry an error code and no
89
+ `Retry-After` — is raised immediately, since retrying cannot lift it.
90
+
80
91
  ## Configuration
81
92
 
82
93
  ```python
83
94
  client = PostStack(
84
95
  api_key="sk_live_...",
85
96
  base_url="https://api.poststack.dev", # default
86
- timeout=30.0, # default (seconds)
97
+ timeout=30.0, # default (seconds), per attempt
98
+ max_retries=3, # default; 0 disables retrying
87
99
  )
88
100
  ```
89
101
 
@@ -95,19 +107,21 @@ client.emails.send({...}, timeout=60.0)
95
107
 
96
108
  ## Resources
97
109
 
98
- - `client.emails` — send, get, list, cancel, reschedule, batch, events, insights
99
- - `client.domains` — create, list, get, verify, update, delete, DMARC
100
- - `client.contacts` — CRUD, import, export, unsubscribe
101
- - `client.contact_properties` — custom properties
102
- - `client.segments` — create, list, get, update, delete, contacts, preview
103
- - `client.templates` — CRUD, publish, duplicate, presets
104
- - `client.webhooks` — CRUD, test, deliveries, replay
105
- - `client.broadcasts` — CRUD, send, test, variants
110
+ - `client.emails` — send, get, list, cancel, reschedule, batch, events, insights, preview, spam_preview
111
+ - `client.domains` — create, list, get, verify, update, delete, DMARC, DKIM rotation, inbox placement
112
+ - `client.contacts` — CRUD, import, export_csv (text; optional `segment_id`), unsubscribe
113
+ - `client.contact_properties` — custom properties, option_usage
114
+ - `client.segments` — create, list, get, update, delete, contacts, preview, members, growth, broadcasts
115
+ - `client.templates` — CRUD, publish, duplicate, render, presets
116
+ - `client.webhooks` — CRUD, test, deliveries, replay, batch_replay, rotate_secret
117
+ - `client.broadcasts` — CRUD, send, test, variants, resend, performance, non_openers, non_clickers, end_ab_test
106
118
  - `client.suppressions` — list, add, remove
107
- - `client.api_keys` — CRUD
108
- - `client.mailboxes` — CRUD, aliases, password
109
- - `client.subscription_topics` — topics + contact subscriptions
110
- - `client.workflows` — CRUD, steps, activate, pause, trigger
119
+ - `client.api_keys` — CRUD, rotate
120
+ - `client.mailboxes` — CRUD, aliases, password, filters, signature, shares, unread_summary
121
+ - `client.inbound_emails` — list, get, attachments, download, draft_reply, reply, forward
122
+ - `client.subscription_topics` — topics (get, update, list_subscribers) + contact subscriptions
123
+ - `client.workflows` — CRUD, graph (get_graph/put_graph/validate_graph), activate, pause, trigger, post_event
124
+ - `client.notification_channels` — Slack/Discord/Telegram alert channels: CRUD, test, deliveries, replay
111
125
  - `client.signup_forms` — CRUD, submit
112
126
  - `client.email_validations` — validate, validate_batch
113
127
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "poststack"
7
- version = "0.5.0"
7
+ version = "0.9.0"
8
8
  description = "Official Python SDK for the PostStack Email API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  from ._client import (
14
14
  DEFAULT_BASE_URL,
15
+ DEFAULT_MAX_RETRIES,
15
16
  AsyncPostStackClient,
16
17
  PostStackClient,
17
18
  )
@@ -25,7 +26,9 @@ from .resources import (
25
26
  AsyncDomainsResource,
26
27
  AsyncEmailsResource,
27
28
  AsyncEmailValidationsResource,
29
+ AsyncInboundEmailsResource,
28
30
  AsyncMailboxesResource,
31
+ AsyncNotificationChannelsResource,
29
32
  AsyncSegmentsResource,
30
33
  AsyncSignupFormsResource,
31
34
  AsyncSubscriptionTopicsResource,
@@ -39,7 +42,9 @@ from .resources import (
39
42
  DomainsResource,
40
43
  EmailsResource,
41
44
  EmailValidationsResource,
45
+ InboundEmailsResource,
42
46
  MailboxesResource,
47
+ NotificationChannelsResource,
43
48
  SegmentsResource,
44
49
  SignupFormsResource,
45
50
  SubscriptionTopicsResource,
@@ -49,7 +54,7 @@ from .resources import (
49
54
  WorkflowsResource,
50
55
  )
51
56
 
52
- __version__ = "0.5.0"
57
+ __version__ = "0.9.0"
53
58
 
54
59
  __all__ = [
55
60
  "PostStack",
@@ -79,9 +84,13 @@ class PostStack:
79
84
  api_key: str,
80
85
  base_url: str = DEFAULT_BASE_URL,
81
86
  timeout: float = 30.0,
87
+ max_retries: int = DEFAULT_MAX_RETRIES,
82
88
  ) -> None:
83
89
  self._client = PostStackClient(
84
- api_key=api_key, base_url=base_url, timeout=timeout
90
+ api_key=api_key,
91
+ base_url=base_url,
92
+ timeout=timeout,
93
+ max_retries=max_retries,
85
94
  )
86
95
  self.emails = EmailsResource(self._client)
87
96
  self.domains = DomainsResource(self._client)
@@ -94,10 +103,12 @@ class PostStack:
94
103
  self.suppressions = SuppressionsResource(self._client)
95
104
  self.api_keys = ApiKeysResource(self._client)
96
105
  self.mailboxes = MailboxesResource(self._client)
106
+ self.inbound_emails = InboundEmailsResource(self._client)
97
107
  self.subscription_topics = SubscriptionTopicsResource(self._client)
98
108
  self.workflows = WorkflowsResource(self._client)
99
109
  self.signup_forms = SignupFormsResource(self._client)
100
110
  self.email_validations = EmailValidationsResource(self._client)
111
+ self.notification_channels = NotificationChannelsResource(self._client)
101
112
 
102
113
  def close(self) -> None:
103
114
  self._client.close()
@@ -123,9 +134,13 @@ class AsyncPostStack:
123
134
  api_key: str,
124
135
  base_url: str = DEFAULT_BASE_URL,
125
136
  timeout: float = 30.0,
137
+ max_retries: int = DEFAULT_MAX_RETRIES,
126
138
  ) -> None:
127
139
  self._client = AsyncPostStackClient(
128
- api_key=api_key, base_url=base_url, timeout=timeout
140
+ api_key=api_key,
141
+ base_url=base_url,
142
+ timeout=timeout,
143
+ max_retries=max_retries,
129
144
  )
130
145
  self.emails = AsyncEmailsResource(self._client)
131
146
  self.domains = AsyncDomainsResource(self._client)
@@ -138,10 +153,14 @@ class AsyncPostStack:
138
153
  self.suppressions = AsyncSuppressionsResource(self._client)
139
154
  self.api_keys = AsyncApiKeysResource(self._client)
140
155
  self.mailboxes = AsyncMailboxesResource(self._client)
156
+ self.inbound_emails = AsyncInboundEmailsResource(self._client)
141
157
  self.subscription_topics = AsyncSubscriptionTopicsResource(self._client)
142
158
  self.workflows = AsyncWorkflowsResource(self._client)
143
159
  self.signup_forms = AsyncSignupFormsResource(self._client)
144
160
  self.email_validations = AsyncEmailValidationsResource(self._client)
161
+ self.notification_channels = AsyncNotificationChannelsResource(
162
+ self._client
163
+ )
145
164
 
146
165
  async def aclose(self) -> None:
147
166
  await self._client.aclose()
@@ -6,7 +6,10 @@ resources can pick the transport they need. Auth is a simple Bearer token.
6
6
  Both clients apply:
7
7
  - a per-attempt timeout (default 30s, override per call)
8
8
  - exponential-backoff retries on transient failures (network errors, 408,
9
- 429, 5xx) up to ``max_retries`` (default 3)
9
+ 429, 5xx) up to ``max_retries`` (default 3). A 429 that carries
10
+ ``Retry-After`` waits that long instead; a 429 that is a quota or cap
11
+ (monthly plan limit, daily send cap) is raised at once — waiting a few
12
+ seconds cannot lift it, so retrying only burns the caller's time
10
13
  - automatic ``Idempotency-Key`` injection on POSTs so that a retry after a
11
14
  network blip does not create a duplicate resource
12
15
  """
@@ -14,6 +17,7 @@ Both clients apply:
14
17
  from __future__ import annotations
15
18
 
16
19
  import asyncio
20
+ import email.utils
17
21
  import random
18
22
  import time
19
23
  import uuid
@@ -48,7 +52,84 @@ def _sdk_version() -> str:
48
52
 
49
53
  return getattr(import_module("poststack"), "__version__", "0.0.0")
50
54
 
51
- RETRYABLE_STATUSES = {408, 429, 500, 502, 503, 504}
55
+ # Explicit non-5xx statuses worth retrying; all 5xx are retried too (see
56
+ # _should_retry_status). Keeps parity with the TS and Go SDKs, which retry the
57
+ # full 5xx range — e.g. a 521/525/599 from an edge proxy.
58
+ RETRYABLE_STATUSES = {408, 429}
59
+
60
+
61
+ # The longest Retry-After the client will sleep through. A server asking for
62
+ # more than this is describing a window the caller should hear about, not one
63
+ # to block a thread on silently.
64
+ MAX_RETRY_AFTER = 60.0
65
+
66
+ # Error codes the API uses for a 429 that is a quota rather than throttling.
67
+ # Not retried: the limit resets on a calendar boundary, not in seconds.
68
+ QUOTA_ERROR_CODES = frozenset(
69
+ {
70
+ "quota_exceeded",
71
+ "monthly_limit_exceeded",
72
+ "daily_limit_exceeded",
73
+ "daily_cap_exceeded",
74
+ "send_limit_exceeded",
75
+ }
76
+ )
77
+
78
+
79
+ def _should_retry_status(status: int) -> bool:
80
+ return status in RETRYABLE_STATUSES or status >= 500
81
+
82
+
83
+ def _parse_retry_after(value: Optional[str]) -> Optional[float]:
84
+ """Seconds from a ``Retry-After`` header (delta-seconds or HTTP-date)."""
85
+ if not value:
86
+ return None
87
+ value = value.strip()
88
+ try:
89
+ return max(0.0, float(value))
90
+ except ValueError:
91
+ pass
92
+ try:
93
+ when = email.utils.parsedate_to_datetime(value)
94
+ except (TypeError, ValueError):
95
+ return None
96
+ if when is None:
97
+ return None
98
+ return max(0.0, when.timestamp() - time.time())
99
+
100
+
101
+ def _error_code(response: httpx.Response) -> Optional[str]:
102
+ try:
103
+ body = response.json()
104
+ except ValueError:
105
+ return None
106
+ code = body.get("code") if isinstance(body, dict) else None
107
+ return code if isinstance(code, str) else None
108
+
109
+
110
+ def _retry_wait(response: httpx.Response, attempt: int) -> Optional[float]:
111
+ """How long to wait before retrying ``response``, or None to not retry.
112
+
113
+ A 429 is the one status whose meaning depends on the body. The API's
114
+ throttling 429s always carry ``Retry-After`` (its rate limiter sets it),
115
+ and that delay is honoured. Its quota 429s (plan limit reached, daily send
116
+ cap) carry an error ``code`` and no ``Retry-After``: those are raised at
117
+ once. A 429 with neither — a proxy in front of the API, say — gets the
118
+ ordinary backoff.
119
+ """
120
+ status = response.status_code
121
+ if not _should_retry_status(status):
122
+ return None
123
+ retry_after = _parse_retry_after(response.headers.get("retry-after"))
124
+ if status == 429:
125
+ code = _error_code(response)
126
+ if code in QUOTA_ERROR_CODES:
127
+ return None
128
+ if retry_after is None and code is not None:
129
+ return None
130
+ if retry_after is not None:
131
+ return retry_after if retry_after <= MAX_RETRY_AFTER else None
132
+ return _retry_delay(attempt)
52
133
 
53
134
 
54
135
  def _build_headers(api_key: str) -> dict[str, str]:
@@ -89,6 +170,8 @@ def _raise_for_status(response: httpx.Response) -> None:
89
170
  error=error_message,
90
171
  code=code,
91
172
  request_id=request_id,
173
+ headers=dict(response.headers),
174
+ retry_after=_parse_retry_after(response.headers.get("retry-after")),
92
175
  )
93
176
 
94
177
 
@@ -158,6 +241,7 @@ class PostStackClient:
158
241
  body: Any = None,
159
242
  extra_headers: Optional[Mapping[str, str]] = None,
160
243
  timeout: Optional[float] = None,
244
+ raw: bool = False,
161
245
  ) -> Any:
162
246
  request_timeout = timeout if timeout is not None else self._timeout
163
247
 
@@ -179,15 +263,14 @@ class PostStackClient:
179
263
  continue
180
264
  raise
181
265
 
182
- if (
183
- response.status_code in RETRYABLE_STATUSES
184
- and attempt < self._max_retries
185
- ):
186
- time.sleep(_retry_delay(attempt))
187
- continue
266
+ if attempt < self._max_retries:
267
+ wait = _retry_wait(response, attempt)
268
+ if wait is not None:
269
+ time.sleep(wait)
270
+ continue
188
271
 
189
272
  _raise_for_status(response)
190
- return _parse_body(response)
273
+ return response.content if raw else _parse_body(response)
191
274
 
192
275
  # If we exit the loop via `continue` on the final attempt, last_exc
193
276
  # holds the most recent error.
@@ -203,6 +286,27 @@ class PostStackClient:
203
286
  ) -> Any:
204
287
  return self._request("GET", path, params=params, timeout=timeout)
205
288
 
289
+ def get_bytes(
290
+ self,
291
+ path: str,
292
+ params: Optional[Mapping[str, Any]] = None,
293
+ *,
294
+ timeout: Optional[float] = None,
295
+ ) -> bytes:
296
+ """GET returning the raw response body (e.g. attachment downloads)."""
297
+ return self._request("GET", path, params=params, timeout=timeout, raw=True)
298
+
299
+ def get_text(
300
+ self,
301
+ path: str,
302
+ params: Optional[Mapping[str, Any]] = None,
303
+ *,
304
+ timeout: Optional[float] = None,
305
+ ) -> str:
306
+ """GET returning the body as text, never JSON-parsed (e.g. CSV)."""
307
+ raw = self._request("GET", path, params=params, timeout=timeout, raw=True)
308
+ return raw.decode("utf-8")
309
+
206
310
  def post(
207
311
  self,
208
312
  path: str,
@@ -227,6 +331,16 @@ class PostStackClient:
227
331
  ) -> Any:
228
332
  return self._request("PATCH", path, body=body, timeout=timeout)
229
333
 
334
+ def put(
335
+ self,
336
+ path: str,
337
+ body: Any = None,
338
+ *,
339
+ timeout: Optional[float] = None,
340
+ ) -> Any:
341
+ # PUT is idempotent by HTTP semantics, so no Idempotency-Key is needed.
342
+ return self._request("PUT", path, body=body, timeout=timeout)
343
+
230
344
  def delete(
231
345
  self,
232
346
  path: str,
@@ -275,6 +389,7 @@ class AsyncPostStackClient:
275
389
  body: Any = None,
276
390
  extra_headers: Optional[Mapping[str, str]] = None,
277
391
  timeout: Optional[float] = None,
392
+ raw: bool = False,
278
393
  ) -> Any:
279
394
  request_timeout = timeout if timeout is not None else self._timeout
280
395
 
@@ -296,15 +411,14 @@ class AsyncPostStackClient:
296
411
  continue
297
412
  raise
298
413
 
299
- if (
300
- response.status_code in RETRYABLE_STATUSES
301
- and attempt < self._max_retries
302
- ):
303
- await asyncio.sleep(_retry_delay(attempt))
304
- continue
414
+ if attempt < self._max_retries:
415
+ wait = _retry_wait(response, attempt)
416
+ if wait is not None:
417
+ await asyncio.sleep(wait)
418
+ continue
305
419
 
306
420
  _raise_for_status(response)
307
- return _parse_body(response)
421
+ return response.content if raw else _parse_body(response)
308
422
 
309
423
  assert last_exc is not None # noqa: S101
310
424
  raise last_exc
@@ -318,6 +432,31 @@ class AsyncPostStackClient:
318
432
  ) -> Any:
319
433
  return await self._request("GET", path, params=params, timeout=timeout)
320
434
 
435
+ async def get_bytes(
436
+ self,
437
+ path: str,
438
+ params: Optional[Mapping[str, Any]] = None,
439
+ *,
440
+ timeout: Optional[float] = None,
441
+ ) -> bytes:
442
+ """GET returning the raw response body (e.g. attachment downloads)."""
443
+ return await self._request(
444
+ "GET", path, params=params, timeout=timeout, raw=True
445
+ )
446
+
447
+ async def get_text(
448
+ self,
449
+ path: str,
450
+ params: Optional[Mapping[str, Any]] = None,
451
+ *,
452
+ timeout: Optional[float] = None,
453
+ ) -> str:
454
+ """GET returning the body as text, never JSON-parsed (e.g. CSV)."""
455
+ raw = await self._request(
456
+ "GET", path, params=params, timeout=timeout, raw=True
457
+ )
458
+ return raw.decode("utf-8")
459
+
321
460
  async def post(
322
461
  self,
323
462
  path: str,
@@ -342,6 +481,16 @@ class AsyncPostStackClient:
342
481
  ) -> Any:
343
482
  return await self._request("PATCH", path, body=body, timeout=timeout)
344
483
 
484
+ async def put(
485
+ self,
486
+ path: str,
487
+ body: Any = None,
488
+ *,
489
+ timeout: Optional[float] = None,
490
+ ) -> Any:
491
+ # PUT is idempotent by HTTP semantics, so no Idempotency-Key is needed.
492
+ return await self._request("PUT", path, body=body, timeout=timeout)
493
+
345
494
  async def delete(
346
495
  self,
347
496
  path: str,
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Optional
5
+ from typing import Mapping, Optional
6
6
 
7
7
 
8
8
  class PostStackError(Exception):
@@ -14,12 +14,19 @@ class PostStackError(Exception):
14
14
  error: str,
15
15
  code: Optional[str] = None,
16
16
  request_id: Optional[str] = None,
17
+ headers: Optional[Mapping[str, str]] = None,
18
+ retry_after: Optional[float] = None,
17
19
  ) -> None:
18
20
  super().__init__(error)
19
21
  self.status_code = status_code
20
22
  self.error = error
23
+ #: Stable machine-readable code, e.g. ``rate_limit_exceeded``.
21
24
  self.code = code
22
25
  self.request_id = request_id
26
+ #: Response headers (lower-cased names), e.g. ``x-ratelimit-remaining``.
27
+ self.headers: dict[str, str] = dict(headers) if headers else {}
28
+ #: Seconds the server asked the caller to wait (``Retry-After``), if any.
29
+ self.retry_after = retry_after
23
30
 
24
31
  def __repr__(self) -> str: # pragma: no cover - cosmetic
25
32
  return (
@@ -13,7 +13,12 @@ from .email_validations import (
13
13
  EmailValidationsResource,
14
14
  )
15
15
  from .emails import AsyncEmailsResource, EmailsResource
16
+ from .inbound_emails import AsyncInboundEmailsResource, InboundEmailsResource
16
17
  from .mailboxes import AsyncMailboxesResource, MailboxesResource
18
+ from .notification_channels import (
19
+ AsyncNotificationChannelsResource,
20
+ NotificationChannelsResource,
21
+ )
17
22
  from .segments import AsyncSegmentsResource, SegmentsResource
18
23
  from .signup_forms import AsyncSignupFormsResource, SignupFormsResource
19
24
  from .subscription_topics import (
@@ -40,8 +45,12 @@ __all__ = [
40
45
  "AsyncEmailValidationsResource",
41
46
  "EmailsResource",
42
47
  "AsyncEmailsResource",
48
+ "InboundEmailsResource",
49
+ "AsyncInboundEmailsResource",
43
50
  "MailboxesResource",
44
51
  "AsyncMailboxesResource",
52
+ "NotificationChannelsResource",
53
+ "AsyncNotificationChannelsResource",
45
54
  "SegmentsResource",
46
55
  "AsyncSegmentsResource",
47
56
  "SignupFormsResource",
@@ -14,6 +14,11 @@ class ApiKeysResource:
14
14
  def create(
15
15
  self, input: Dict[str, Any], *, timeout: Optional[float] = None
16
16
  ) -> Dict[str, Any]:
17
+ """Create a key: ``name``, ``permission`` (``full_access`` or
18
+ ``sending_access``), optional ``mode`` (``live``/``test``) and
19
+ ``domain_id``. Minting a ``full_access`` key with a ``full_access``
20
+ API key requires ``allow_full_access: True``. The plaintext ``key`` is
21
+ returned once."""
17
22
  return self._client.post("/api-keys", input, timeout=timeout)
18
23
 
19
24
  def list(
@@ -22,6 +27,8 @@ class ApiKeysResource:
22
27
  *,
23
28
  timeout: Optional[float] = None,
24
29
  ) -> Dict[str, Any]:
30
+ """Every API key on the team. The endpoint does not paginate and
31
+ ignores ``params``; kept only so existing callers do not break."""
25
32
  return self._client.get("/api-keys", params, timeout=timeout)
26
33
 
27
34
  def get(
@@ -34,6 +41,11 @@ class ApiKeysResource:
34
41
  ) -> Dict[str, Any]:
35
42
  return self._client.delete(f"/api-keys/{id}", timeout=timeout)
36
43
 
44
+ def rotate(
45
+ self, id: int, *, timeout: Optional[float] = None
46
+ ) -> Dict[str, Any]:
47
+ return self._client.post(f"/api-keys/{id}/rotate", timeout=timeout)
48
+
37
49
 
38
50
  class AsyncApiKeysResource:
39
51
  def __init__(self, client: AsyncPostStackClient) -> None:
@@ -42,6 +54,11 @@ class AsyncApiKeysResource:
42
54
  async def create(
43
55
  self, input: Dict[str, Any], *, timeout: Optional[float] = None
44
56
  ) -> Dict[str, Any]:
57
+ """Create a key: ``name``, ``permission`` (``full_access`` or
58
+ ``sending_access``), optional ``mode`` (``live``/``test``) and
59
+ ``domain_id``. Minting a ``full_access`` key with a ``full_access``
60
+ API key requires ``allow_full_access: True``. The plaintext ``key`` is
61
+ returned once."""
45
62
  return await self._client.post("/api-keys", input, timeout=timeout)
46
63
 
47
64
  async def list(
@@ -50,6 +67,8 @@ class AsyncApiKeysResource:
50
67
  *,
51
68
  timeout: Optional[float] = None,
52
69
  ) -> Dict[str, Any]:
70
+ """Every API key on the team. The endpoint does not paginate and
71
+ ignores ``params``; kept only so existing callers do not break."""
53
72
  return await self._client.get("/api-keys", params, timeout=timeout)
54
73
 
55
74
  async def get(
@@ -61,3 +80,10 @@ class AsyncApiKeysResource:
61
80
  self, id: int, *, timeout: Optional[float] = None
62
81
  ) -> Dict[str, Any]:
63
82
  return await self._client.delete(f"/api-keys/{id}", timeout=timeout)
83
+
84
+ async def rotate(
85
+ self, id: int, *, timeout: Optional[float] = None
86
+ ) -> Dict[str, Any]:
87
+ return await self._client.post(
88
+ f"/api-keys/{id}/rotate", timeout=timeout
89
+ )