poststack 0.4.0__tar.gz → 0.5.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 (28) hide show
  1. {poststack-0.4.0 → poststack-0.5.0}/PKG-INFO +2 -2
  2. {poststack-0.4.0 → poststack-0.5.0}/pyproject.toml +6 -2
  3. {poststack-0.4.0 → poststack-0.5.0}/src/poststack/__init__.py +1 -5
  4. {poststack-0.4.0 → poststack-0.5.0}/src/poststack/_client.py +351 -333
  5. {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/contacts.py +14 -0
  6. {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/webhooks.py +54 -0
  7. {poststack-0.4.0 → poststack-0.5.0}/src/poststack/types.py +49 -1
  8. poststack-0.5.0/tests/__init__.py +0 -0
  9. {poststack-0.4.0 → poststack-0.5.0}/.gitignore +0 -0
  10. {poststack-0.4.0 → poststack-0.5.0}/LICENSE +0 -0
  11. {poststack-0.4.0 → poststack-0.5.0}/README.md +0 -0
  12. {poststack-0.4.0 → poststack-0.5.0}/src/poststack/_errors.py +0 -0
  13. /poststack-0.4.0/tests/__init__.py → /poststack-0.5.0/src/poststack/py.typed +0 -0
  14. {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/__init__.py +0 -0
  15. {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/api_keys.py +0 -0
  16. {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/broadcasts.py +0 -0
  17. {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/contact_properties.py +0 -0
  18. {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/domains.py +0 -0
  19. {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/email_validations.py +0 -0
  20. {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/emails.py +0 -0
  21. {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/mailboxes.py +0 -0
  22. {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/segments.py +0 -0
  23. {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/signup_forms.py +0 -0
  24. {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/subscription_topics.py +0 -0
  25. {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/suppressions.py +0 -0
  26. {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/templates.py +0 -0
  27. {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/workflows.py +0 -0
  28. {poststack-0.4.0 → poststack-0.5.0}/tests/test_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: poststack
3
- Version: 0.4.0
3
+ Version: 0.5.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
@@ -22,7 +22,7 @@ Classifier: Topic :: Communications :: Email
22
22
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
23
  Requires-Python: >=3.10
24
24
  Requires-Dist: httpx>=0.25.0
25
- Requires-Dist: pydantic>=2.0.0
25
+ Requires-Dist: pydantic>=2.4.0
26
26
  Provides-Extra: dev
27
27
  Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
28
28
  Requires-Dist: pytest>=7.0.0; extra == 'dev'
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "poststack"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Official Python SDK for the PostStack Email API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -25,7 +25,8 @@ classifiers = [
25
25
  ]
26
26
  dependencies = [
27
27
  "httpx>=0.25.0",
28
- "pydantic>=2.0.0",
28
+ # 2.4.0 floor closes GHSA-mr82-8j83-vxmv (regex DoS in 2.0–2.3.x).
29
+ "pydantic>=2.4.0",
29
30
  ]
30
31
 
31
32
  [project.optional-dependencies]
@@ -43,6 +44,9 @@ Issues = "https://github.com/getpoststack/python-sdk/issues"
43
44
 
44
45
  [tool.hatch.build.targets.wheel]
45
46
  packages = ["src/poststack"]
47
+ # PEP 561: ship the `py.typed` marker so downstream type checkers (mypy,
48
+ # pyright) honor our annotations.
49
+ include = ["src/poststack/py.typed"]
46
50
 
47
51
  [tool.hatch.build.targets.sdist]
48
52
  include = [
@@ -10,8 +10,6 @@ Public API::
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
- from typing import Optional
14
-
15
13
  from ._client import (
16
14
  DEFAULT_BASE_URL,
17
15
  AsyncPostStackClient,
@@ -51,7 +49,7 @@ from .resources import (
51
49
  WorkflowsResource,
52
50
  )
53
51
 
54
- __version__ = "0.4.0"
52
+ __version__ = "0.5.0"
55
53
 
56
54
  __all__ = [
57
55
  "PostStack",
@@ -155,5 +153,3 @@ class AsyncPostStack:
155
153
  await self.aclose()
156
154
 
157
155
 
158
- # Re-export for advanced callers.
159
- _: Optional[type] = None # noqa: E305 - keeps ruff/mypy happy with Optional import
@@ -1,333 +1,351 @@
1
- """HTTP client used by every resource module.
2
-
3
- Implements two parallel clients (sync + async) with identical interfaces so
4
- resources can pick the transport they need. Auth is a simple Bearer token.
5
-
6
- Both clients apply:
7
- - a per-attempt timeout (default 30s, override per call)
8
- - exponential-backoff retries on transient failures (network errors, 408,
9
- 429, 5xx) up to ``max_retries`` (default 3)
10
- - automatic ``Idempotency-Key`` injection on POSTs so that a retry after a
11
- network blip does not create a duplicate resource
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- import asyncio
17
- import random
18
- import time
19
- import uuid
20
- from typing import Any, Mapping, Optional
21
-
22
- import httpx
23
-
24
- from ._errors import PostStackError
25
-
26
- DEFAULT_BASE_URL = "https://api.poststack.dev"
27
- DEFAULT_TIMEOUT = 30.0
28
- DEFAULT_MAX_RETRIES = 3
29
- BASE_RETRY_DELAY = 0.25
30
- SDK_VERSION = "0.5.0"
31
- USER_AGENT = f"PostStack-Python-SDK/{SDK_VERSION}"
32
-
33
- RETRYABLE_STATUSES = {408, 429, 500, 502, 503, 504}
34
-
35
-
36
- def _build_headers(api_key: str) -> dict[str, str]:
37
- return {
38
- "Authorization": f"Bearer {api_key}",
39
- "Content-Type": "application/json",
40
- "User-Agent": USER_AGENT,
41
- }
42
-
43
-
44
- def _clean_params(
45
- params: Optional[Mapping[str, Any]],
46
- ) -> Optional[dict[str, str]]:
47
- if not params:
48
- return None
49
- out: dict[str, str] = {}
50
- for key, value in params.items():
51
- if value is None:
52
- continue
53
- out[key] = str(value)
54
- return out or None
55
-
56
-
57
- def _raise_for_status(response: httpx.Response) -> None:
58
- if response.is_success:
59
- return
60
- request_id = response.headers.get("x-request-id")
61
- try:
62
- body = response.json()
63
- except ValueError:
64
- body = {}
65
- error_message = (
66
- body.get("error") if isinstance(body, dict) else None
67
- ) or response.reason_phrase or "Unknown error"
68
- code = body.get("code") if isinstance(body, dict) else None
69
- raise PostStackError(
70
- status_code=response.status_code,
71
- error=error_message,
72
- code=code,
73
- request_id=request_id,
74
- )
75
-
76
-
77
- def _parse_body(response: httpx.Response) -> Any:
78
- # Some endpoints (e.g. CSV export) return non-JSON. Fall back to text.
79
- content_type = response.headers.get("content-type", "")
80
- if "application/json" in content_type:
81
- return response.json()
82
- text = response.text
83
- if not text:
84
- return None
85
- try:
86
- return response.json()
87
- except ValueError:
88
- return text
89
-
90
-
91
- def _retry_delay(attempt: int) -> float:
92
- """Full-jitter exponential backoff — pick uniformly in [0, base*2**attempt)."""
93
- return random.random() * (BASE_RETRY_DELAY * (2**attempt))
94
-
95
-
96
- def _idempotency_headers() -> dict[str, str]:
97
- return {"Idempotency-Key": str(uuid.uuid4())}
98
-
99
-
100
- class PostStackClient:
101
- """Synchronous HTTP client."""
102
-
103
- def __init__(
104
- self,
105
- api_key: str,
106
- base_url: str = DEFAULT_BASE_URL,
107
- timeout: float = DEFAULT_TIMEOUT,
108
- max_retries: int = DEFAULT_MAX_RETRIES,
109
- transport: Optional[httpx.BaseTransport] = None,
110
- ) -> None:
111
- self._base_url = base_url.rstrip("/")
112
- self._timeout = timeout
113
- self._max_retries = max_retries
114
- self._httpx = httpx.Client(
115
- base_url=self._base_url,
116
- headers=_build_headers(api_key),
117
- timeout=timeout,
118
- transport=transport,
119
- )
120
-
121
- # Lifecycle ------------------------------------------------------------
122
-
123
- def close(self) -> None:
124
- self._httpx.close()
125
-
126
- def __enter__(self) -> "PostStackClient":
127
- return self
128
-
129
- def __exit__(self, *args: Any) -> None:
130
- self.close()
131
-
132
- # HTTP methods ---------------------------------------------------------
133
-
134
- def _request(
135
- self,
136
- method: str,
137
- path: str,
138
- *,
139
- params: Optional[Mapping[str, Any]] = None,
140
- body: Any = None,
141
- extra_headers: Optional[Mapping[str, str]] = None,
142
- timeout: Optional[float] = None,
143
- ) -> Any:
144
- request_timeout = timeout if timeout is not None else self._timeout
145
-
146
- last_exc: Optional[BaseException] = None
147
- for attempt in range(self._max_retries + 1):
148
- try:
149
- response = self._httpx.request(
150
- method,
151
- path,
152
- params=_clean_params(params),
153
- json=body if body is not None else None,
154
- headers=dict(extra_headers) if extra_headers else None,
155
- timeout=request_timeout,
156
- )
157
- except httpx.TransportError as exc:
158
- last_exc = exc
159
- if attempt < self._max_retries:
160
- time.sleep(_retry_delay(attempt))
161
- continue
162
- raise
163
-
164
- if (
165
- response.status_code in RETRYABLE_STATUSES
166
- and attempt < self._max_retries
167
- ):
168
- time.sleep(_retry_delay(attempt))
169
- continue
170
-
171
- _raise_for_status(response)
172
- return _parse_body(response)
173
-
174
- # If we exit the loop via `continue` on the final attempt, last_exc
175
- # holds the most recent error.
176
- assert last_exc is not None # noqa: S101
177
- raise last_exc
178
-
179
- def get(
180
- self,
181
- path: str,
182
- params: Optional[Mapping[str, Any]] = None,
183
- *,
184
- timeout: Optional[float] = None,
185
- ) -> Any:
186
- return self._request("GET", path, params=params, timeout=timeout)
187
-
188
- def post(
189
- self,
190
- path: str,
191
- body: Any = None,
192
- *,
193
- timeout: Optional[float] = None,
194
- ) -> Any:
195
- return self._request(
196
- "POST",
197
- path,
198
- body=body,
199
- extra_headers=_idempotency_headers(),
200
- timeout=timeout,
201
- )
202
-
203
- def patch(
204
- self,
205
- path: str,
206
- body: Any,
207
- *,
208
- timeout: Optional[float] = None,
209
- ) -> Any:
210
- return self._request("PATCH", path, body=body, timeout=timeout)
211
-
212
- def delete(
213
- self,
214
- path: str,
215
- *,
216
- timeout: Optional[float] = None,
217
- ) -> Any:
218
- return self._request("DELETE", path, timeout=timeout)
219
-
220
-
221
- class AsyncPostStackClient:
222
- """Asynchronous HTTP client."""
223
-
224
- def __init__(
225
- self,
226
- api_key: str,
227
- base_url: str = DEFAULT_BASE_URL,
228
- timeout: float = DEFAULT_TIMEOUT,
229
- max_retries: int = DEFAULT_MAX_RETRIES,
230
- transport: Optional[httpx.AsyncBaseTransport] = None,
231
- ) -> None:
232
- self._base_url = base_url.rstrip("/")
233
- self._timeout = timeout
234
- self._max_retries = max_retries
235
- self._httpx = httpx.AsyncClient(
236
- base_url=self._base_url,
237
- headers=_build_headers(api_key),
238
- timeout=timeout,
239
- transport=transport,
240
- )
241
-
242
- async def aclose(self) -> None:
243
- await self._httpx.aclose()
244
-
245
- async def __aenter__(self) -> "AsyncPostStackClient":
246
- return self
247
-
248
- async def __aexit__(self, *args: Any) -> None:
249
- await self.aclose()
250
-
251
- async def _request(
252
- self,
253
- method: str,
254
- path: str,
255
- *,
256
- params: Optional[Mapping[str, Any]] = None,
257
- body: Any = None,
258
- extra_headers: Optional[Mapping[str, str]] = None,
259
- timeout: Optional[float] = None,
260
- ) -> Any:
261
- request_timeout = timeout if timeout is not None else self._timeout
262
-
263
- last_exc: Optional[BaseException] = None
264
- for attempt in range(self._max_retries + 1):
265
- try:
266
- response = await self._httpx.request(
267
- method,
268
- path,
269
- params=_clean_params(params),
270
- json=body if body is not None else None,
271
- headers=dict(extra_headers) if extra_headers else None,
272
- timeout=request_timeout,
273
- )
274
- except httpx.TransportError as exc:
275
- last_exc = exc
276
- if attempt < self._max_retries:
277
- await asyncio.sleep(_retry_delay(attempt))
278
- continue
279
- raise
280
-
281
- if (
282
- response.status_code in RETRYABLE_STATUSES
283
- and attempt < self._max_retries
284
- ):
285
- await asyncio.sleep(_retry_delay(attempt))
286
- continue
287
-
288
- _raise_for_status(response)
289
- return _parse_body(response)
290
-
291
- assert last_exc is not None # noqa: S101
292
- raise last_exc
293
-
294
- async def get(
295
- self,
296
- path: str,
297
- params: Optional[Mapping[str, Any]] = None,
298
- *,
299
- timeout: Optional[float] = None,
300
- ) -> Any:
301
- return await self._request("GET", path, params=params, timeout=timeout)
302
-
303
- async def post(
304
- self,
305
- path: str,
306
- body: Any = None,
307
- *,
308
- timeout: Optional[float] = None,
309
- ) -> Any:
310
- return await self._request(
311
- "POST",
312
- path,
313
- body=body,
314
- extra_headers=_idempotency_headers(),
315
- timeout=timeout,
316
- )
317
-
318
- async def patch(
319
- self,
320
- path: str,
321
- body: Any,
322
- *,
323
- timeout: Optional[float] = None,
324
- ) -> Any:
325
- return await self._request("PATCH", path, body=body, timeout=timeout)
326
-
327
- async def delete(
328
- self,
329
- path: str,
330
- *,
331
- timeout: Optional[float] = None,
332
- ) -> Any:
333
- return await self._request("DELETE", path, timeout=timeout)
1
+ """HTTP client used by every resource module.
2
+
3
+ Implements two parallel clients (sync + async) with identical interfaces so
4
+ resources can pick the transport they need. Auth is a simple Bearer token.
5
+
6
+ Both clients apply:
7
+ - a per-attempt timeout (default 30s, override per call)
8
+ - exponential-backoff retries on transient failures (network errors, 408,
9
+ 429, 5xx) up to ``max_retries`` (default 3)
10
+ - automatic ``Idempotency-Key`` injection on POSTs so that a retry after a
11
+ network blip does not create a duplicate resource
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import random
18
+ import time
19
+ import uuid
20
+ from typing import Any, Mapping, Optional
21
+
22
+ import httpx
23
+
24
+ from ._errors import PostStackError
25
+
26
+ DEFAULT_BASE_URL = "https://api.poststack.dev"
27
+ DEFAULT_TIMEOUT = 30.0
28
+ DEFAULT_MAX_RETRIES = 3
29
+ BASE_RETRY_DELAY = 0.25
30
+
31
+
32
+ def _sdk_version() -> str:
33
+ """Read the package version lazily to avoid a circular import.
34
+
35
+ ``__init__.py`` imports from this module before assigning
36
+ ``__version__``, so resolving at import time would observe the wrong
37
+ value (or fail). Calling at request-build time is cheap and keeps
38
+ ``__version__`` as the single source of truth (mirrored in
39
+ pyproject.toml at release).
40
+ """
41
+ from importlib.metadata import PackageNotFoundError, version
42
+
43
+ try:
44
+ return version("poststack")
45
+ except PackageNotFoundError:
46
+ # Editable install / source tree without an installed dist.
47
+ from importlib import import_module
48
+
49
+ return getattr(import_module("poststack"), "__version__", "0.0.0")
50
+
51
+ RETRYABLE_STATUSES = {408, 429, 500, 502, 503, 504}
52
+
53
+
54
+ def _build_headers(api_key: str) -> dict[str, str]:
55
+ return {
56
+ "Authorization": f"Bearer {api_key}",
57
+ "Content-Type": "application/json",
58
+ "User-Agent": f"PostStack-Python-SDK/{_sdk_version()}",
59
+ }
60
+
61
+
62
+ def _clean_params(
63
+ params: Optional[Mapping[str, Any]],
64
+ ) -> Optional[dict[str, str]]:
65
+ if not params:
66
+ return None
67
+ out: dict[str, str] = {}
68
+ for key, value in params.items():
69
+ if value is None:
70
+ continue
71
+ out[key] = str(value)
72
+ return out or None
73
+
74
+
75
+ def _raise_for_status(response: httpx.Response) -> None:
76
+ if response.is_success:
77
+ return
78
+ request_id = response.headers.get("x-request-id")
79
+ try:
80
+ body = response.json()
81
+ except ValueError:
82
+ body = {}
83
+ error_message = (
84
+ body.get("error") if isinstance(body, dict) else None
85
+ ) or response.reason_phrase or "Unknown error"
86
+ code = body.get("code") if isinstance(body, dict) else None
87
+ raise PostStackError(
88
+ status_code=response.status_code,
89
+ error=error_message,
90
+ code=code,
91
+ request_id=request_id,
92
+ )
93
+
94
+
95
+ def _parse_body(response: httpx.Response) -> Any:
96
+ # Some endpoints (e.g. CSV export) return non-JSON. Fall back to text.
97
+ content_type = response.headers.get("content-type", "")
98
+ if "application/json" in content_type:
99
+ return response.json()
100
+ text = response.text
101
+ if not text:
102
+ return None
103
+ try:
104
+ return response.json()
105
+ except ValueError:
106
+ return text
107
+
108
+
109
+ def _retry_delay(attempt: int) -> float:
110
+ """Full-jitter exponential backoff — pick uniformly in [0, base*2**attempt)."""
111
+ return random.random() * (BASE_RETRY_DELAY * (2**attempt))
112
+
113
+
114
+ def _idempotency_headers() -> dict[str, str]:
115
+ return {"Idempotency-Key": str(uuid.uuid4())}
116
+
117
+
118
+ class PostStackClient:
119
+ """Synchronous HTTP client."""
120
+
121
+ def __init__(
122
+ self,
123
+ api_key: str,
124
+ base_url: str = DEFAULT_BASE_URL,
125
+ timeout: float = DEFAULT_TIMEOUT,
126
+ max_retries: int = DEFAULT_MAX_RETRIES,
127
+ transport: Optional[httpx.BaseTransport] = None,
128
+ ) -> None:
129
+ self._base_url = base_url.rstrip("/")
130
+ self._timeout = timeout
131
+ self._max_retries = max_retries
132
+ self._httpx = httpx.Client(
133
+ base_url=self._base_url,
134
+ headers=_build_headers(api_key),
135
+ timeout=timeout,
136
+ transport=transport,
137
+ )
138
+
139
+ # Lifecycle ------------------------------------------------------------
140
+
141
+ def close(self) -> None:
142
+ self._httpx.close()
143
+
144
+ def __enter__(self) -> "PostStackClient":
145
+ return self
146
+
147
+ def __exit__(self, *args: Any) -> None:
148
+ self.close()
149
+
150
+ # HTTP methods ---------------------------------------------------------
151
+
152
+ def _request(
153
+ self,
154
+ method: str,
155
+ path: str,
156
+ *,
157
+ params: Optional[Mapping[str, Any]] = None,
158
+ body: Any = None,
159
+ extra_headers: Optional[Mapping[str, str]] = None,
160
+ timeout: Optional[float] = None,
161
+ ) -> Any:
162
+ request_timeout = timeout if timeout is not None else self._timeout
163
+
164
+ last_exc: Optional[BaseException] = None
165
+ for attempt in range(self._max_retries + 1):
166
+ try:
167
+ response = self._httpx.request(
168
+ method,
169
+ path,
170
+ params=_clean_params(params),
171
+ json=body if body is not None else None,
172
+ headers=dict(extra_headers) if extra_headers else None,
173
+ timeout=request_timeout,
174
+ )
175
+ except httpx.TransportError as exc:
176
+ last_exc = exc
177
+ if attempt < self._max_retries:
178
+ time.sleep(_retry_delay(attempt))
179
+ continue
180
+ raise
181
+
182
+ if (
183
+ response.status_code in RETRYABLE_STATUSES
184
+ and attempt < self._max_retries
185
+ ):
186
+ time.sleep(_retry_delay(attempt))
187
+ continue
188
+
189
+ _raise_for_status(response)
190
+ return _parse_body(response)
191
+
192
+ # If we exit the loop via `continue` on the final attempt, last_exc
193
+ # holds the most recent error.
194
+ assert last_exc is not None # noqa: S101
195
+ raise last_exc
196
+
197
+ def get(
198
+ self,
199
+ path: str,
200
+ params: Optional[Mapping[str, Any]] = None,
201
+ *,
202
+ timeout: Optional[float] = None,
203
+ ) -> Any:
204
+ return self._request("GET", path, params=params, timeout=timeout)
205
+
206
+ def post(
207
+ self,
208
+ path: str,
209
+ body: Any = None,
210
+ *,
211
+ timeout: Optional[float] = None,
212
+ ) -> Any:
213
+ return self._request(
214
+ "POST",
215
+ path,
216
+ body=body,
217
+ extra_headers=_idempotency_headers(),
218
+ timeout=timeout,
219
+ )
220
+
221
+ def patch(
222
+ self,
223
+ path: str,
224
+ body: Any,
225
+ *,
226
+ timeout: Optional[float] = None,
227
+ ) -> Any:
228
+ return self._request("PATCH", path, body=body, timeout=timeout)
229
+
230
+ def delete(
231
+ self,
232
+ path: str,
233
+ *,
234
+ timeout: Optional[float] = None,
235
+ ) -> Any:
236
+ return self._request("DELETE", path, timeout=timeout)
237
+
238
+
239
+ class AsyncPostStackClient:
240
+ """Asynchronous HTTP client."""
241
+
242
+ def __init__(
243
+ self,
244
+ api_key: str,
245
+ base_url: str = DEFAULT_BASE_URL,
246
+ timeout: float = DEFAULT_TIMEOUT,
247
+ max_retries: int = DEFAULT_MAX_RETRIES,
248
+ transport: Optional[httpx.AsyncBaseTransport] = None,
249
+ ) -> None:
250
+ self._base_url = base_url.rstrip("/")
251
+ self._timeout = timeout
252
+ self._max_retries = max_retries
253
+ self._httpx = httpx.AsyncClient(
254
+ base_url=self._base_url,
255
+ headers=_build_headers(api_key),
256
+ timeout=timeout,
257
+ transport=transport,
258
+ )
259
+
260
+ async def aclose(self) -> None:
261
+ await self._httpx.aclose()
262
+
263
+ async def __aenter__(self) -> "AsyncPostStackClient":
264
+ return self
265
+
266
+ async def __aexit__(self, *args: Any) -> None:
267
+ await self.aclose()
268
+
269
+ async def _request(
270
+ self,
271
+ method: str,
272
+ path: str,
273
+ *,
274
+ params: Optional[Mapping[str, Any]] = None,
275
+ body: Any = None,
276
+ extra_headers: Optional[Mapping[str, str]] = None,
277
+ timeout: Optional[float] = None,
278
+ ) -> Any:
279
+ request_timeout = timeout if timeout is not None else self._timeout
280
+
281
+ last_exc: Optional[BaseException] = None
282
+ for attempt in range(self._max_retries + 1):
283
+ try:
284
+ response = await self._httpx.request(
285
+ method,
286
+ path,
287
+ params=_clean_params(params),
288
+ json=body if body is not None else None,
289
+ headers=dict(extra_headers) if extra_headers else None,
290
+ timeout=request_timeout,
291
+ )
292
+ except httpx.TransportError as exc:
293
+ last_exc = exc
294
+ if attempt < self._max_retries:
295
+ await asyncio.sleep(_retry_delay(attempt))
296
+ continue
297
+ raise
298
+
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
305
+
306
+ _raise_for_status(response)
307
+ return _parse_body(response)
308
+
309
+ assert last_exc is not None # noqa: S101
310
+ raise last_exc
311
+
312
+ async def get(
313
+ self,
314
+ path: str,
315
+ params: Optional[Mapping[str, Any]] = None,
316
+ *,
317
+ timeout: Optional[float] = None,
318
+ ) -> Any:
319
+ return await self._request("GET", path, params=params, timeout=timeout)
320
+
321
+ async def post(
322
+ self,
323
+ path: str,
324
+ body: Any = None,
325
+ *,
326
+ timeout: Optional[float] = None,
327
+ ) -> Any:
328
+ return await self._request(
329
+ "POST",
330
+ path,
331
+ body=body,
332
+ extra_headers=_idempotency_headers(),
333
+ timeout=timeout,
334
+ )
335
+
336
+ async def patch(
337
+ self,
338
+ path: str,
339
+ body: Any,
340
+ *,
341
+ timeout: Optional[float] = None,
342
+ ) -> Any:
343
+ return await self._request("PATCH", path, body=body, timeout=timeout)
344
+
345
+ async def delete(
346
+ self,
347
+ path: str,
348
+ *,
349
+ timeout: Optional[float] = None,
350
+ ) -> Any:
351
+ return await self._request("DELETE", path, timeout=timeout)
@@ -32,6 +32,13 @@ class ContactsResource:
32
32
  f"/contacts/{quote(id, safe='')}", timeout=timeout
33
33
  )
34
34
 
35
+ def get_by_email(
36
+ self, email: str, *, timeout: Optional[float] = None
37
+ ) -> Dict[str, Any]:
38
+ return self._client.get(
39
+ f"/contacts/by-email/{quote(email, safe='')}", timeout=timeout
40
+ )
41
+
35
42
  def update(
36
43
  self,
37
44
  id: str,
@@ -92,6 +99,13 @@ class AsyncContactsResource:
92
99
  f"/contacts/{quote(id, safe='')}", timeout=timeout
93
100
  )
94
101
 
102
+ async def get_by_email(
103
+ self, email: str, *, timeout: Optional[float] = None
104
+ ) -> Dict[str, Any]:
105
+ return await self._client.get(
106
+ f"/contacts/by-email/{quote(email, safe='')}", timeout=timeout
107
+ )
108
+
95
109
  async def update(
96
110
  self,
97
111
  id: str,
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import hashlib
6
+ import hmac
5
7
  from typing import Any, Dict, Mapping, Optional
6
8
 
7
9
  from .._client import AsyncPostStackClient, PostStackClient
@@ -14,6 +16,14 @@ class WebhooksResource:
14
16
  def create(
15
17
  self, input: Dict[str, Any], *, timeout: Optional[float] = None
16
18
  ) -> Dict[str, Any]:
19
+ """Create a webhook.
20
+
21
+ The server response is ``{"webhook": {...}, "signingSecret": "..."}``.
22
+ Store ``signingSecret`` immediately — it's only surfaced here (the
23
+ server keeps an encrypted copy and afterwards only returns a
24
+ masked prefix). Pass it to :meth:`verify` to validate inbound
25
+ deliveries.
26
+ """
17
27
  return self._client.post("/webhooks", input, timeout=timeout)
18
28
 
19
29
  def get(
@@ -71,6 +81,45 @@ class WebhooksResource:
71
81
  timeout=timeout,
72
82
  )
73
83
 
84
+ @staticmethod
85
+ def verify(
86
+ payload: str | bytes, signature_header: str, secret: str
87
+ ) -> bool:
88
+ """Verify an incoming webhook against the ``X-PostStack-Signature``
89
+ header.
90
+
91
+ Header format is ``sha256=<hex-hmac>``. The HMAC is SHA-256 of the
92
+ raw JSON request body keyed by the webhook's plaintext signing
93
+ secret. Pass ``payload`` exactly as received — re-serializing
94
+ through ``json.loads/dumps`` will change byte-for-byte content and
95
+ the signature will not match.
96
+
97
+ Returns ``False`` on any shape mismatch (missing/malformed header,
98
+ wrong length, mismatched HMAC) so callers can ``return 401`` on
99
+ the bare boolean. Uses :func:`hmac.compare_digest` for the byte
100
+ comparison.
101
+ """
102
+ if (
103
+ not isinstance(signature_header, str)
104
+ or not signature_header.startswith("sha256=")
105
+ ):
106
+ return False
107
+
108
+ provided_hex = signature_header[len("sha256=") :].strip().lower()
109
+ if not provided_hex or any(
110
+ c not in "0123456789abcdef" for c in provided_hex
111
+ ):
112
+ return False
113
+ if len(provided_hex) % 2 != 0:
114
+ return False
115
+
116
+ body = payload.encode("utf-8") if isinstance(payload, str) else payload
117
+ expected_hex = hmac.new(
118
+ secret.encode("utf-8"), body, hashlib.sha256
119
+ ).hexdigest()
120
+
121
+ return hmac.compare_digest(expected_hex, provided_hex)
122
+
74
123
 
75
124
  class AsyncWebhooksResource:
76
125
  def __init__(self, client: AsyncPostStackClient) -> None:
@@ -79,6 +128,8 @@ class AsyncWebhooksResource:
79
128
  async def create(
80
129
  self, input: Dict[str, Any], *, timeout: Optional[float] = None
81
130
  ) -> Dict[str, Any]:
131
+ """Create a webhook. See :meth:`WebhooksResource.create` for the
132
+ response shape and signing-secret semantics."""
82
133
  return await self._client.post("/webhooks", input, timeout=timeout)
83
134
 
84
135
  async def get(
@@ -137,3 +188,6 @@ class AsyncWebhooksResource:
137
188
  f"/webhooks/{id}/deliveries/{delivery_id}/replay",
138
189
  timeout=timeout,
139
190
  )
191
+
192
+ # Verify is sync-only — pure crypto, no IO. Alias for ergonomics.
193
+ verify = staticmethod(WebhooksResource.verify)
@@ -226,6 +226,29 @@ class ImportContactsResult(_Model):
226
226
  # ---------------------------------------------------------------------------
227
227
 
228
228
 
229
+ SegmentComparator = Literal[
230
+ "equals", "not_equals", "contains", "starts_with", "ends_with"
231
+ ]
232
+ SegmentOperator = Literal["and", "or"]
233
+
234
+
235
+ class SegmentCondition(_Model):
236
+ """One leaf condition in a segment rules tree.
237
+
238
+ The field is **comparator**, not operator — `operator` is reserved for
239
+ the rules-tree boolean connective on `SegmentRules`.
240
+ """
241
+
242
+ field: str
243
+ comparator: SegmentComparator
244
+ value: str
245
+
246
+
247
+ class SegmentRules(_Model):
248
+ operator: SegmentOperator
249
+ conditions: List[SegmentCondition]
250
+
251
+
229
252
  class Segment(_Model):
230
253
  id: str
231
254
  name: str
@@ -234,8 +257,9 @@ class Segment(_Model):
234
257
 
235
258
 
236
259
  class SegmentPreviewResult(_Model):
260
+ """Server returns count only — no `sample` field."""
261
+
237
262
  count: int
238
- sample: List[Contact]
239
263
 
240
264
 
241
265
  # ---------------------------------------------------------------------------
@@ -277,6 +301,21 @@ class Webhook(_Model):
277
301
  events: List[str]
278
302
  active: bool
279
303
  created_at: str = Field(alias="createdAt")
304
+ # Plaintext signing secret — populated only on the response from
305
+ # `Webhooks.create()` (the server keeps an encrypted copy and only
306
+ # surfaces a masked prefix on subsequent reads).
307
+ signing_secret: Optional[str] = Field(
308
+ default=None, alias="signingSecret"
309
+ )
310
+
311
+
312
+ class CreateWebhookResult(_Model):
313
+ """Response shape for `Webhooks.create()` — wraps the webhook plus its
314
+ plaintext `signing_secret` so callers can store the secret immediately.
315
+ """
316
+
317
+ webhook: Webhook
318
+ signing_secret: str = Field(alias="signingSecret")
280
319
 
281
320
 
282
321
  class WebhookDelivery(_Model):
@@ -352,6 +391,10 @@ class ApiKey(_Model):
352
391
  last_used_at: Optional[str] = Field(default=None, alias="lastUsedAt")
353
392
  expires_at: Optional[str] = Field(default=None, alias="expiresAt")
354
393
  created_at: str = Field(alias="createdAt")
394
+ # Plaintext secret — only present on `ApiKeys.create()` /
395
+ # `ApiKeys.rotate()`. List/get responses omit it (server stores only
396
+ # a peppered hash and cannot re-issue).
397
+ key: Optional[str] = None
355
398
 
356
399
 
357
400
  # ---------------------------------------------------------------------------
@@ -489,9 +532,14 @@ __all__ = [
489
532
  "ContactProperty",
490
533
  "ImportContactsResult",
491
534
  "Segment",
535
+ "SegmentComparator",
536
+ "SegmentCondition",
537
+ "SegmentOperator",
492
538
  "SegmentPreviewResult",
539
+ "SegmentRules",
493
540
  "Template",
494
541
  "TemplatePreset",
542
+ "CreateWebhookResult",
495
543
  "Webhook",
496
544
  "WebhookDelivery",
497
545
  "Broadcast",
File without changes
File without changes
File without changes
File without changes