posthaste-email 0.1.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.
- posthaste_email-0.1.0/.gitignore +70 -0
- posthaste_email-0.1.0/LICENSE +21 -0
- posthaste_email-0.1.0/PKG-INFO +454 -0
- posthaste_email-0.1.0/README.md +432 -0
- posthaste_email-0.1.0/pyproject.toml +64 -0
- posthaste_email-0.1.0/src/posthaste/__init__.py +135 -0
- posthaste_email-0.1.0/src/posthaste/_redaction.py +60 -0
- posthaste_email-0.1.0/src/posthaste/client.py +120 -0
- posthaste_email-0.1.0/src/posthaste/errors.py +568 -0
- posthaste_email-0.1.0/src/posthaste/http.py +558 -0
- posthaste_email-0.1.0/src/posthaste/pagination.py +134 -0
- posthaste_email-0.1.0/src/posthaste/py.typed +0 -0
- posthaste_email-0.1.0/src/posthaste/resources.py +1115 -0
- posthaste_email-0.1.0/src/posthaste/types.py +715 -0
- posthaste_email-0.1.0/src/posthaste/webhooks.py +214 -0
- posthaste_email-0.1.0/tests/conftest.py +54 -0
- posthaste_email-0.1.0/tests/fixtures/webhook_signatures.json +55 -0
- posthaste_email-0.1.0/tests/stub_transport.py +127 -0
- posthaste_email-0.1.0/tests/test_client.py +447 -0
- posthaste_email-0.1.0/tests/test_errors.py +238 -0
- posthaste_email-0.1.0/tests/test_key_never_leaks.py +106 -0
- posthaste_email-0.1.0/tests/test_no_real_recipients.py +95 -0
- posthaste_email-0.1.0/tests/test_packaging.py +126 -0
- posthaste_email-0.1.0/tests/test_pagination.py +201 -0
- posthaste_email-0.1.0/tests/test_retry.py +257 -0
- posthaste_email-0.1.0/tests/test_webhooks.py +296 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
.pnpm-store/
|
|
4
|
+
|
|
5
|
+
# Build output
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
.next/
|
|
9
|
+
.turbo/
|
|
10
|
+
*.tsbuildinfo
|
|
11
|
+
|
|
12
|
+
# Environment — NOTE the suffixed variants.
|
|
13
|
+
# A bare `.env` rule matches ONLY a file named exactly `.env`. Real credentials
|
|
14
|
+
# have been committed in other repos here precisely because `.env.app` and
|
|
15
|
+
# `terraform.tfvars` fell outside a bare rule. Be explicit; keep *.example tracked.
|
|
16
|
+
.env
|
|
17
|
+
.env.*
|
|
18
|
+
!.env.example
|
|
19
|
+
!.env.*.example
|
|
20
|
+
*.tfvars
|
|
21
|
+
!*.tfvars.example
|
|
22
|
+
|
|
23
|
+
# Secrets, keys, certs — DKIM private keys never enter the tree.
|
|
24
|
+
secrets/
|
|
25
|
+
*.pem
|
|
26
|
+
*.key
|
|
27
|
+
*.p12
|
|
28
|
+
*.keystore
|
|
29
|
+
dkim-private*
|
|
30
|
+
!*.pub.pem
|
|
31
|
+
|
|
32
|
+
# Mail spool / queue state — durable buffers, never committed
|
|
33
|
+
spool/
|
|
34
|
+
queue/
|
|
35
|
+
*.eml
|
|
36
|
+
*.ndjson
|
|
37
|
+
|
|
38
|
+
# Python build and cache output (packages/sdk-python)
|
|
39
|
+
__pycache__/
|
|
40
|
+
*.py[cod]
|
|
41
|
+
.pytest_cache/
|
|
42
|
+
.mypy_cache/
|
|
43
|
+
*.egg-info/
|
|
44
|
+
.venv/
|
|
45
|
+
|
|
46
|
+
# PHP (packages/laravel). `composer.lock` is deliberately NOT committed: this
|
|
47
|
+
# is a library, so the lock file describes one machine's idea of the dependency
|
|
48
|
+
# graph rather than an application's. CI resolves fresh against the constraints
|
|
49
|
+
# in composer.json, which is what actually catches an incompatible Laravel
|
|
50
|
+
# release before a customer does.
|
|
51
|
+
vendor/
|
|
52
|
+
composer.lock
|
|
53
|
+
.phpunit.cache/
|
|
54
|
+
.phpunit.result.cache
|
|
55
|
+
|
|
56
|
+
# Logs, coverage, test output
|
|
57
|
+
*.log
|
|
58
|
+
coverage/
|
|
59
|
+
playwright-report/
|
|
60
|
+
test-results/
|
|
61
|
+
|
|
62
|
+
# OS / editor
|
|
63
|
+
.DS_Store
|
|
64
|
+
Thumbs.db
|
|
65
|
+
.idea/
|
|
66
|
+
.vscode/
|
|
67
|
+
*.swp
|
|
68
|
+
|
|
69
|
+
# Agent worktrees — scratch checkouts, never part of the repo.
|
|
70
|
+
.claude/worktrees/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Posthaste
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: posthaste-email
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Posthaste transactional email API.
|
|
5
|
+
Project-URL: Homepage, https://posthastemail.dev
|
|
6
|
+
Project-URL: Documentation, https://posthastemail.dev/docs/python
|
|
7
|
+
Project-URL: Source, https://github.com/posthastemail/posthaste-python
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: email,posthaste,smtp,transactional-email,webhooks
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: Communications :: Email
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# posthaste
|
|
24
|
+
|
|
25
|
+
The official Python SDK for the [Posthaste](https://posthastemail.dev) transactional email API.
|
|
26
|
+
|
|
27
|
+
**No third-party dependencies.** `urllib.request` and `hmac`, nothing else — so it installs
|
|
28
|
+
instantly, adds nothing to your lockfile, and brings no transitive supply chain for your security
|
|
29
|
+
team to audit.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install posthaste
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Requires Python 3.9 or newer. Fully typed, with a `py.typed` marker, so mypy and pyright see every
|
|
36
|
+
annotation.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Quickstart
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import os
|
|
44
|
+
from posthaste import Posthaste
|
|
45
|
+
|
|
46
|
+
posthaste = Posthaste(api_key=os.environ["POSTHASTE_API_KEY"])
|
|
47
|
+
|
|
48
|
+
sent = posthaste.emails.send(
|
|
49
|
+
from_="Acme <billing@acme.example>",
|
|
50
|
+
to="customer@example.com",
|
|
51
|
+
subject="Your receipt",
|
|
52
|
+
html="<p>Thanks for your order.</p>",
|
|
53
|
+
text="Thanks for your order.",
|
|
54
|
+
idempotency_key=f"receipt-{order_id}",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
sent["id"] # 'msg_AZLm3kQ8T2Sf9pXbNc7HrQ'
|
|
58
|
+
sent["status"] # 'queued' | 'duplicate'
|
|
59
|
+
sent["duplicate"] # False — a new send, not an idempotency replay
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`from_` has the trailing underscore because `from` is a Python keyword. It is the only field whose
|
|
63
|
+
Python name differs for that reason, and the SDK maps it back to `from` on the wire.
|
|
64
|
+
|
|
65
|
+
## Snake in, camel out
|
|
66
|
+
|
|
67
|
+
Requests are Python: snake_case keyword arguments, mapped to the API's camelCase on the way out.
|
|
68
|
+
Responses come back as the API's own JSON, **unchanged** — camelCase keys and all.
|
|
69
|
+
|
|
70
|
+
That asymmetry is deliberate. Rewriting a response means deciding what to do with a field this SDK
|
|
71
|
+
version has never heard of, and every answer is bad: drop it and you lose data the API sent you;
|
|
72
|
+
keep it under its original name and the object is half-translated. The response is the API's
|
|
73
|
+
document, and a document is not ours to rewrite. What the SDK adds instead is a `TypedDict` for
|
|
74
|
+
every shape, so your editor and type checker know the keys without anything being reshaped at
|
|
75
|
+
runtime.
|
|
76
|
+
|
|
77
|
+
## Authentication
|
|
78
|
+
|
|
79
|
+
Posthaste authenticates with an API key sent as a bearer token. Create one in the dashboard under
|
|
80
|
+
**Settings → API keys**; it is shown exactly once.
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
posthaste = Posthaste(
|
|
84
|
+
api_key=os.environ["POSTHASTE_API_KEY"], # ph_live_… or ph_test_…
|
|
85
|
+
base_url="https://api.posthastemail.dev", # the default
|
|
86
|
+
timeout=30.0, # per ATTEMPT, not per call
|
|
87
|
+
max_retries=2, # retries after the first attempt
|
|
88
|
+
max_retry_delay=60.0, # longest Retry-After it will sit through
|
|
89
|
+
headers={"x-trace-id": trace_id}, # added to every request
|
|
90
|
+
)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
A key is server-side only: it carries no user identity and must never reach a browser. It carries
|
|
94
|
+
**scopes**, and a call that needs one the key does not hold raises `PermissionDeniedError` naming
|
|
95
|
+
the missing scope.
|
|
96
|
+
|
|
97
|
+
**The key is never printed.** It is not in `repr(client)`, not in any exception message, and not in
|
|
98
|
+
a formatted traceback — including when a server echoes it back in an error. That matters more than
|
|
99
|
+
it sounds: a credential leaks through a `repr` pasted into a ticket and through a traceback an error
|
|
100
|
+
reporter uploaded, far more often than through a deliberate log line.
|
|
101
|
+
|
|
102
|
+
### Self-hosting
|
|
103
|
+
|
|
104
|
+
Point `base_url` at your own deployment. Trailing slashes are stripped.
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
posthaste = Posthaste(api_key=key, base_url="https://mail.internal.example/api")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Bringing your own HTTP client
|
|
111
|
+
|
|
112
|
+
`Transport` is a `Protocol`, so anything with a matching `request` method works — no inheritance
|
|
113
|
+
required. Useful behind a corporate proxy, for tracing, and in tests.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
import httpx
|
|
117
|
+
from posthaste import Posthaste
|
|
118
|
+
from posthaste.http import HttpResponse
|
|
119
|
+
|
|
120
|
+
class HttpxTransport:
|
|
121
|
+
def __init__(self, **kwargs):
|
|
122
|
+
# follow_redirects stays OFF: a redirect would re-send the bearer token
|
|
123
|
+
# to whatever host the Location names.
|
|
124
|
+
self._client = httpx.Client(follow_redirects=False, **kwargs)
|
|
125
|
+
|
|
126
|
+
def request(self, method, url, headers, body, timeout):
|
|
127
|
+
response = self._client.request(
|
|
128
|
+
method, url, headers=headers, content=body, timeout=timeout
|
|
129
|
+
)
|
|
130
|
+
return HttpResponse(response.status_code, response.headers, response.content)
|
|
131
|
+
|
|
132
|
+
posthaste = Posthaste(api_key=key, transport=HttpxTransport(proxy="http://proxy.internal:8080"))
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Errors
|
|
136
|
+
|
|
137
|
+
Every failure raises a `PosthasteError`, including the ones that never reached a server. Under that
|
|
138
|
+
base there is a real hierarchy, so `except RateLimited` reads the way a Python caller expects.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from posthaste import (
|
|
142
|
+
ContentBlockedError,
|
|
143
|
+
DomainNotVerifiedError,
|
|
144
|
+
QuotaExhausted,
|
|
145
|
+
RateLimited,
|
|
146
|
+
SuppressedError,
|
|
147
|
+
PosthasteError,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
posthaste.emails.send(from_=sender, to=recipient, text=body)
|
|
152
|
+
|
|
153
|
+
except SuppressedError as error:
|
|
154
|
+
# Permanent. error.suppression.reason is 'hard_bounce' | 'complaint' |
|
|
155
|
+
# 'spam_trap' | 'manual' | 'unsubscribe'.
|
|
156
|
+
drop(error.suppression)
|
|
157
|
+
|
|
158
|
+
except DomainNotVerifiedError:
|
|
159
|
+
ask_them_to_publish_the_dkim_record()
|
|
160
|
+
|
|
161
|
+
except ContentBlockedError as error:
|
|
162
|
+
# error.check names the check; error.findings is the whole lint report,
|
|
163
|
+
# warnings included, so one fix pass can address everything.
|
|
164
|
+
show(error.findings)
|
|
165
|
+
|
|
166
|
+
except RateLimited as error:
|
|
167
|
+
# Transient, measured in seconds. Covers `rate_limited` (your request rate)
|
|
168
|
+
# and `platform_paused` (our own daily send ceiling, which is not yours and
|
|
169
|
+
# which no plan change touches).
|
|
170
|
+
retry_after(error.retry_after_seconds or 60)
|
|
171
|
+
|
|
172
|
+
except QuotaExhausted as error:
|
|
173
|
+
# Hours or days away. Not a retry — an alert.
|
|
174
|
+
alert_ops(error)
|
|
175
|
+
|
|
176
|
+
except PosthasteError as error:
|
|
177
|
+
log.warning("posthaste refused: %s %s", error.status, error.type)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**Branch on the class or the type, never on the status.** Four different refusals arrive as `429`.
|
|
181
|
+
`RateLimited` and `QuotaExhausted` are deliberately siblings rather than parent and child, because
|
|
182
|
+
an `except` clause written for one must never catch the other — that confusion is exactly what makes
|
|
183
|
+
a naive retry loop hammer a wall for the rest of the month.
|
|
184
|
+
|
|
185
|
+
Every exception carries `status`, `type`, `message`, `fields` when the server sent them,
|
|
186
|
+
`retry_after_seconds` when the server said, and `body` for the extra keys a refusal carries. Two
|
|
187
|
+
types are synthesised by the SDK and never sent by the API: `APIConnectionError` (the request never
|
|
188
|
+
opened) and `APITimeoutError` (it opened and never answered). Both have `status == 0`.
|
|
189
|
+
|
|
190
|
+
| Exception | Covers |
|
|
191
|
+
| ---------------------------------------- | ----------------------------------------------------- |
|
|
192
|
+
| `AuthenticationError` | `unauthorized`, `unauthenticated`, `email_unverified` |
|
|
193
|
+
| `PermissionDeniedError` | `forbidden` — a real key missing a scope |
|
|
194
|
+
| `InvalidRequestError` | `invalid_request`; read `.fields` |
|
|
195
|
+
| `NotFoundError` | `not_found` |
|
|
196
|
+
| `ConflictError` | `conflict`, `address_taken` |
|
|
197
|
+
| `UnprocessableError` | every other 422 — permanent, never retried |
|
|
198
|
+
| `SuppressedError` | `suppressed`; read `.suppression` |
|
|
199
|
+
| `DomainNotVerifiedError` | `domain_not_verified` |
|
|
200
|
+
| `ContentBlockedError` | `content_blocked`; read `.check` and `.findings` |
|
|
201
|
+
| `AttachmentError` | the four attachment refusals |
|
|
202
|
+
| `ScheduleError` | `schedule_too_far`, `not_scheduled` |
|
|
203
|
+
| `TemplateError` / `StreamError` | unknown or unusable template / stream |
|
|
204
|
+
| `RateLimited` | `rate_limited`, `platform_paused` |
|
|
205
|
+
| `QuotaExhausted` | `daily_limit_reached`, `monthly_limit_reached` |
|
|
206
|
+
| `ServerError` | 5xx |
|
|
207
|
+
| `APIConnectionError` / `APITimeoutError` | never reached the server |
|
|
208
|
+
|
|
209
|
+
A refusal reason added to the API after your SDK version still arrives as a typed exception — the
|
|
210
|
+
class is chosen from the HTTP status when the type is unrecognised, never from a `KeyError`.
|
|
211
|
+
|
|
212
|
+
## Retries
|
|
213
|
+
|
|
214
|
+
The SDK retries `408`, `429` and `5xx`, and connection failures, with exponential backoff and full
|
|
215
|
+
jitter, honouring `Retry-After`. Two rules make that safe rather than merely automatic.
|
|
216
|
+
|
|
217
|
+
- **Quota is never retried in process.** `daily_limit_reached` and `monthly_limit_reached` raise
|
|
218
|
+
immediately with the wait attached, so you can queue, delay or alert instead of hammering a wall
|
|
219
|
+
the calendar has to move before it opens.
|
|
220
|
+
- **Nothing is repeated that repeating could duplicate.** A send is retried _only_ when you supplied
|
|
221
|
+
an `idempotency_key`; `webhooks.create` and `streams.create` are never retried. Reads, deletes,
|
|
222
|
+
`domains.create` (a duplicate is a `409`) and `suppressions.create` (an upsert) are all safe, and
|
|
223
|
+
are retried.
|
|
224
|
+
|
|
225
|
+
A `Retry-After` longer than `max_retry_delay` (60s by default) is honoured by **not** retrying: a
|
|
226
|
+
quota that frees at midnight is not something to block a request handler on, and sleeping through it
|
|
227
|
+
would look like a hang.
|
|
228
|
+
|
|
229
|
+
## Pagination
|
|
230
|
+
|
|
231
|
+
Every list on the API pages. `list()` returns one page; `auto_paginate()` is a generator over every
|
|
232
|
+
page; `list_all()` drains it into a list up to a ceiling you choose.
|
|
233
|
+
|
|
234
|
+
```python
|
|
235
|
+
# One page.
|
|
236
|
+
page = posthaste.messages.list(limit=50, status="bounced")
|
|
237
|
+
page["data"] # list[MessageSummary]
|
|
238
|
+
page["hasMore"]
|
|
239
|
+
page["nextCursor"] # pass as `before` on the next request
|
|
240
|
+
|
|
241
|
+
# Every page, as an ordinary for loop. Breaking out stops fetching.
|
|
242
|
+
for message in posthaste.messages.auto_paginate(status="bounced"):
|
|
243
|
+
suppress(message["to"])
|
|
244
|
+
|
|
245
|
+
# Or into a list, with a ceiling you choose. 1,000 by default.
|
|
246
|
+
recent = posthaste.messages.list_all(max_items=500, status="bounced")
|
|
247
|
+
|
|
248
|
+
# Every list has the same three, including the ones that send no `hasMore` at
|
|
249
|
+
# all — domains, webhooks, API keys and invoices. This is all sixty invoices,
|
|
250
|
+
# not the first fifty.
|
|
251
|
+
invoices = posthaste.billing.list_all_invoices()
|
|
252
|
+
|
|
253
|
+
# Billing history is the odd one out: it pages FORWARD from a sequence number
|
|
254
|
+
# in `after`, oldest first. Hence the different names — the helper knows.
|
|
255
|
+
for event in posthaste.billing.auto_paginate_history():
|
|
256
|
+
record(event["seq"], event["type"])
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
**Neither stopping rule is correct on its own, which is why the helper exists.** `while
|
|
260
|
+
next_cursor:` never terminates on the endpoints that return a non-null cursor on their last page.
|
|
261
|
+
And `if not page.get("hasMore"): break` stops after ONE page on `/v1/domains`, `/v1/webhooks`,
|
|
262
|
+
`/v1/api-keys`, `/v1/billing/invoices` and `/v1/billing/history`, because those send no `hasMore` at
|
|
263
|
+
all — the read is `None`, which is falsy, which looks exactly like "that was everything".
|
|
264
|
+
`auto_paginate` consults both signals and treats each as authoritative only where the server
|
|
265
|
+
actually sends it.
|
|
266
|
+
|
|
267
|
+
The `max_items` ceiling on `list_all` is not garnish. Draining an unbounded list into memory is how
|
|
268
|
+
a convenience helper becomes an incident on the one account with four million messages, so the
|
|
269
|
+
caller chooses the bound.
|
|
270
|
+
|
|
271
|
+
## Verifying webhooks
|
|
272
|
+
|
|
273
|
+
`verify_webhook` parses the signature header **by key** rather than positionally (`v1=` exists so a
|
|
274
|
+
`v2=` can be added beside it), compares in constant time, and rejects a timestamp more than 300
|
|
275
|
+
seconds old _or_ more than 300 seconds in the future — a future timestamp is not clock skew to be
|
|
276
|
+
generous about, it is an attacker buying an unlimited replay window.
|
|
277
|
+
|
|
278
|
+
```python
|
|
279
|
+
# Flask
|
|
280
|
+
from flask import Flask, request
|
|
281
|
+
from posthaste import DELIVERY_ID_HEADER, SIGNATURE_HEADER, verify_webhook
|
|
282
|
+
|
|
283
|
+
app = Flask(__name__)
|
|
284
|
+
|
|
285
|
+
@app.post("/hooks/posthaste")
|
|
286
|
+
def posthaste_webhook():
|
|
287
|
+
# THE RAW BYTES. request.get_json() would hand you an object, and the
|
|
288
|
+
# signature covers the bytes we sent — not the object they decode to.
|
|
289
|
+
result = verify_webhook(
|
|
290
|
+
request.get_data(),
|
|
291
|
+
request.headers.get(SIGNATURE_HEADER),
|
|
292
|
+
os.environ["POSTHASTE_WEBHOOK_SECRET"],
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
if not result:
|
|
296
|
+
# 'malformed_header' | 'unsupported_version' | 'timestamp_too_old'
|
|
297
|
+
# | 'timestamp_in_future' | 'signature_mismatch'
|
|
298
|
+
app.logger.warning("rejected webhook: %s", result.reason)
|
|
299
|
+
return "", 400
|
|
300
|
+
|
|
301
|
+
event = json.loads(request.get_data())
|
|
302
|
+
|
|
303
|
+
# Deduplicate on the delivery id — a retry is not a second event.
|
|
304
|
+
enqueue(request.headers.get(DELIVERY_ID_HEADER), event)
|
|
305
|
+
|
|
306
|
+
# Acknowledge fast; do the work elsewhere. We time out after 10 seconds.
|
|
307
|
+
return "", 204
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
```python
|
|
311
|
+
# FastAPI
|
|
312
|
+
from fastapi import FastAPI, Request, Response
|
|
313
|
+
from posthaste import parse_webhook_event
|
|
314
|
+
|
|
315
|
+
app = FastAPI()
|
|
316
|
+
|
|
317
|
+
@app.post("/hooks/posthaste")
|
|
318
|
+
async def posthaste_webhook(request: Request):
|
|
319
|
+
raw = await request.body() # never await request.json()
|
|
320
|
+
event = parse_webhook_event(
|
|
321
|
+
raw,
|
|
322
|
+
request.headers.get("posthaste-signature"),
|
|
323
|
+
os.environ["POSTHASTE_WEBHOOK_SECRET"],
|
|
324
|
+
)
|
|
325
|
+
if event is None:
|
|
326
|
+
return Response(status_code=400)
|
|
327
|
+
await enqueue(request.headers.get("posthaste-delivery-id"), event)
|
|
328
|
+
return Response(status_code=204)
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
```python
|
|
332
|
+
# Django
|
|
333
|
+
from django.http import HttpResponse
|
|
334
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
335
|
+
from posthaste import verify_webhook
|
|
336
|
+
|
|
337
|
+
@csrf_exempt
|
|
338
|
+
def posthaste_webhook(request):
|
|
339
|
+
if not verify_webhook(
|
|
340
|
+
request.body, # bytes, exactly as received
|
|
341
|
+
request.headers.get("Posthaste-Signature"),
|
|
342
|
+
settings.POSTHASTE_WEBHOOK_SECRET,
|
|
343
|
+
):
|
|
344
|
+
return HttpResponse(status=400)
|
|
345
|
+
enqueue(request.headers.get("Posthaste-Delivery-Id"), json.loads(request.body))
|
|
346
|
+
return HttpResponse(status=204)
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
**Verify over the raw bytes.** A body that has been parsed and re-serialised will never verify —
|
|
350
|
+
`json.loads` followed by `json.dumps` is not a byte-level round trip, and the signature covers the
|
|
351
|
+
bytes we sent, not the object they decode to. This is the single most common reason verification
|
|
352
|
+
"mysteriously" fails, and no amount of correct key handling rescues it.
|
|
353
|
+
|
|
354
|
+
`parse_webhook_event` verifies and JSON-parses in one step, returning `None` on any failure, for the
|
|
355
|
+
common case where a bad delivery just gets a `400`. Use `verify_webhook` directly when the reason
|
|
356
|
+
matters. The signing secret is the one returned once when the endpoint was created.
|
|
357
|
+
|
|
358
|
+
## Every method
|
|
359
|
+
|
|
360
|
+
```text
|
|
361
|
+
account.me() GET /v1/me
|
|
362
|
+
account.verify() GET /v1/account/verify
|
|
363
|
+
account.usage() GET /v1/usage
|
|
364
|
+
|
|
365
|
+
domains.create(name) POST /v1/domains
|
|
366
|
+
domains.list(...) GET /v1/domains
|
|
367
|
+
domains.auto_paginate(...) GET /v1/domains (all pages)
|
|
368
|
+
domains.list_all(...) GET /v1/domains (all pages)
|
|
369
|
+
domains.verify(domain_id) POST /v1/domains/:id/verify
|
|
370
|
+
domains.delete(domain_id) DELETE /v1/domains/:id
|
|
371
|
+
domains.setup(domain_id) GET /v1/domains/:id/setup
|
|
372
|
+
domains.connect_cloudflare(domain_id, ...) POST /v1/domains/:id/cloudflare
|
|
373
|
+
domains.disconnect_cloudflare() DELETE /v1/account/cloudflare
|
|
374
|
+
|
|
375
|
+
emails.send(...) POST /v1/emails
|
|
376
|
+
emails.send_batch(messages) POST /v1/emails/batch
|
|
377
|
+
emails.cancel_schedule(message_id) DELETE /v1/emails/:id/schedule
|
|
378
|
+
|
|
379
|
+
messages.list(...) GET /v1/messages
|
|
380
|
+
messages.auto_paginate(...) GET /v1/messages (all pages)
|
|
381
|
+
messages.list_all(...) GET /v1/messages (all pages)
|
|
382
|
+
messages.get(message_id) GET /v1/messages/:id
|
|
383
|
+
messages.download_attachment(msg, att) GET /v1/messages/:id/attachments/:attachmentId
|
|
384
|
+
messages.stats(...) GET /v1/stats/messages
|
|
385
|
+
|
|
386
|
+
suppressions.list(...) GET /v1/suppressions
|
|
387
|
+
suppressions.auto_paginate(...) GET /v1/suppressions (all pages)
|
|
388
|
+
suppressions.list_all(...) GET /v1/suppressions (all pages)
|
|
389
|
+
suppressions.create(address, reason=...) POST /v1/suppressions
|
|
390
|
+
suppressions.delete(address) DELETE /v1/suppressions/:address
|
|
391
|
+
|
|
392
|
+
webhooks.create(url, event_types=...) POST /v1/webhooks
|
|
393
|
+
webhooks.list(...) GET /v1/webhooks
|
|
394
|
+
webhooks.auto_paginate(...) GET /v1/webhooks (all pages)
|
|
395
|
+
webhooks.list_all(...) GET /v1/webhooks (all pages)
|
|
396
|
+
webhooks.delete(webhook_id) DELETE /v1/webhooks/:id
|
|
397
|
+
|
|
398
|
+
templates.list() GET /v1/templates
|
|
399
|
+
templates.get(template_id) GET /v1/templates/:id
|
|
400
|
+
templates.versions(template_id) GET /v1/templates/:id/versions
|
|
401
|
+
templates.preview(template_id, ...) POST /v1/templates/:id/preview
|
|
402
|
+
templates.create(...) POST /v1/templates
|
|
403
|
+
templates.update(template_id, ...) PATCH /v1/templates/:id
|
|
404
|
+
templates.delete(template_id) DELETE /v1/templates/:id
|
|
405
|
+
|
|
406
|
+
streams.list() GET /v1/streams
|
|
407
|
+
streams.create(slug=..., name=...) POST /v1/streams
|
|
408
|
+
|
|
409
|
+
api_keys.list(...) GET /v1/api-keys
|
|
410
|
+
api_keys.auto_paginate(...) GET /v1/api-keys (all pages)
|
|
411
|
+
api_keys.list_all(...) GET /v1/api-keys (all pages)
|
|
412
|
+
|
|
413
|
+
billing.get() GET /v1/billing
|
|
414
|
+
billing.history(...) GET /v1/billing/history
|
|
415
|
+
billing.auto_paginate_history(...) GET /v1/billing/history (all pages)
|
|
416
|
+
billing.list_all_history(...) GET /v1/billing/history (all pages)
|
|
417
|
+
billing.invoices(...) GET /v1/billing/invoices
|
|
418
|
+
billing.auto_paginate_invoices(...) GET /v1/billing/invoices (all pages)
|
|
419
|
+
billing.list_all_invoices(...) GET /v1/billing/invoices (all pages)
|
|
420
|
+
billing.invoice(invoice_id) GET /v1/billing/invoices/:id
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
**Deliberately absent:** creating and revoking API keys, and everything else that requires a
|
|
424
|
+
signed-in person rather than a key — checkout, plan changes, profile edits, team management. Those
|
|
425
|
+
endpoints refuse a bearer token outright, and a method that can only ever raise
|
|
426
|
+
`PermissionDeniedError` is worse than no method. `/admin/v1/*` is not part of the public surface at
|
|
427
|
+
all, and `/v1/inbound/*` is absent because a customer cannot use it: the DNS records handed out on
|
|
428
|
+
domain verification contain no MX, so nothing would arrive.
|
|
429
|
+
|
|
430
|
+
Also absent, and for a different reason: `/v1/analytics/delivery`, `/v1/audit/*` and
|
|
431
|
+
`/v1/sub-accounts`. Those are real API-key endpoints that the TypeScript SDK does not cover either;
|
|
432
|
+
this package is at parity with it rather than ahead of it, and adding them here first would put the
|
|
433
|
+
two clients out of step.
|
|
434
|
+
|
|
435
|
+
## Development
|
|
436
|
+
|
|
437
|
+
```bash
|
|
438
|
+
python3 -m pytest packages/sdk-python
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
The suite makes no network calls — a socket attempt raises before DNS is consulted, and a test
|
|
442
|
+
asserts that the guard is load-bearing rather than decorative. It also refuses to let any file name
|
|
443
|
+
an address at a domain we do not own.
|
|
444
|
+
|
|
445
|
+
The webhook fixtures are generated by the **TypeScript** signer that signs live deliveries, so the
|
|
446
|
+
Python verifier is checked against real signatures rather than against itself:
|
|
447
|
+
|
|
448
|
+
```bash
|
|
449
|
+
pnpm exec tsx packages/sdk-python/scripts/generate-webhook-fixtures.ts
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
## Licence
|
|
453
|
+
|
|
454
|
+
MIT.
|