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.
- {poststack-0.5.0 → poststack-0.9.0}/PKG-INFO +30 -16
- {poststack-0.5.0 → poststack-0.9.0}/README.md +28 -14
- {poststack-0.5.0 → poststack-0.9.0}/pyproject.toml +1 -1
- {poststack-0.5.0 → poststack-0.9.0}/src/poststack/__init__.py +22 -3
- {poststack-0.5.0 → poststack-0.9.0}/src/poststack/_client.py +165 -16
- {poststack-0.5.0 → poststack-0.9.0}/src/poststack/_errors.py +8 -1
- {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/__init__.py +9 -0
- {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/api_keys.py +26 -0
- {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/broadcasts.py +134 -0
- {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/contact_properties.py +16 -0
- {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/contacts.py +48 -7
- {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/domains.py +119 -2
- {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/emails.py +46 -0
- poststack-0.9.0/src/poststack/resources/inbound_emails.py +147 -0
- poststack-0.9.0/src/poststack/resources/mailboxes.py +345 -0
- poststack-0.9.0/src/poststack/resources/notification_channels.py +179 -0
- {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/segments.py +72 -0
- poststack-0.9.0/src/poststack/resources/subscription_topics.py +184 -0
- {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/templates.py +38 -0
- poststack-0.9.0/src/poststack/resources/webhooks.py +303 -0
- {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/workflows.py +84 -51
- {poststack-0.5.0 → poststack-0.9.0}/src/poststack/types.py +159 -22
- poststack-0.9.0/tests/test_api_parity.py +491 -0
- {poststack-0.5.0 → poststack-0.9.0}/tests/test_client.py +119 -13
- poststack-0.9.0/tests/test_webhooks_verify.py +60 -0
- poststack-0.5.0/src/poststack/resources/mailboxes.py +0 -157
- poststack-0.5.0/src/poststack/resources/subscription_topics.py +0 -119
- poststack-0.5.0/src/poststack/resources/webhooks.py +0 -193
- {poststack-0.5.0 → poststack-0.9.0}/.gitignore +0 -0
- {poststack-0.5.0 → poststack-0.9.0}/LICENSE +0 -0
- {poststack-0.5.0 → poststack-0.9.0}/src/poststack/py.typed +0 -0
- {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/email_validations.py +0 -0
- {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/signup_forms.py +0 -0
- {poststack-0.5.0 → poststack-0.9.0}/src/poststack/resources/suppressions.py +0 -0
- {poststack-0.5.0 → poststack-0.9.0}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: poststack
|
|
3
|
-
Version: 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,
|
|
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.
|
|
141
|
-
- `client.
|
|
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,
|
|
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.
|
|
110
|
-
- `client.
|
|
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
|
|
|
@@ -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.
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
+
)
|