webhooks-cc 0.1.2__tar.gz → 0.2.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.
- {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/PKG-INFO +76 -6
- {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/README.md +75 -5
- {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/pyproject.toml +1 -1
- {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/__init__.py +51 -2
- {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/_crypto.py +25 -5
- webhooks_cc-0.2.0/src/webhooks_cc/_js.py +66 -0
- {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/client.py +534 -20
- {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/durations.py +4 -2
- webhooks_cc-0.2.0/src/webhooks_cc/email.py +151 -0
- {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/matchers.py +21 -11
- webhooks_cc-0.2.0/src/webhooks_cc/models.py +924 -0
- {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/verify.py +52 -68
- webhooks_cc-0.1.2/src/webhooks_cc/email.py +0 -78
- webhooks_cc-0.1.2/src/webhooks_cc/models.py +0 -403
- {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/.gitignore +0 -0
- {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/LICENSE +0 -0
- {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/_sentinel.py +0 -0
- {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/_sse.py +0 -0
- {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/errors.py +0 -0
- {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/py.typed +0 -0
- {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/pytest_plugin.py +0 -0
- {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/templates.py +0 -0
- {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/testing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: webhooks-cc
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python SDK for webhooks.cc: capture, wait for, sign and verify webhooks and test emails
|
|
5
5
|
Project-URL: Homepage, https://webhooks.cc
|
|
6
6
|
Project-URL: Documentation, https://github.com/kroqdotdev/webhooks-cc/tree/main/packages/sdk-python#readme
|
|
@@ -53,7 +53,7 @@ with WebhooksCC() as client:
|
|
|
53
53
|
|
|
54
54
|
Durations take `"500ms"`, `"30s"`, `"5m"`, `"2h"`, `"7d"`, a `timedelta`, or a number of milliseconds (the unit every webhooks.cc SDK uses, so prefer a string where you mean seconds). Times such as `since` and `Request.received_at` are milliseconds since the epoch.
|
|
55
55
|
|
|
56
|
-
Matchers combine with `match_all()` and `match_any()`, and any function that takes a `Request` and returns a bool works too: `match_method`, `match_path` (globs
|
|
56
|
+
Matchers combine with `match_all()` and `match_any()`, and any function that takes a `Request` and returns a bool works too: `match_method`, `match_path` (globs: `*` within a segment, `**` across segments, `?` for one character), `match_header`, `match_query_param`, `match_body_path`, `match_body_subset`, `match_content_type`, `match_verified`, `match_unverified` and `match_email`.
|
|
57
57
|
|
|
58
58
|
## pytest
|
|
59
59
|
|
|
@@ -87,6 +87,33 @@ link = extract_link(email) # "https://app.example.com/confirm?token=...
|
|
|
87
87
|
|
|
88
88
|
`email.email` holds the parsed message: `subject`, `from_`, `to`, `text`, `html`, attachments, and the SPF, DKIM and DMARC results.
|
|
89
89
|
|
|
90
|
+
## Email fields, reply text and provider shapes
|
|
91
|
+
|
|
92
|
+
An endpoint can pick up to 20 named fields out of every email it captures. Each reads the `subject`, `text`, `html`, `from`, `to` or one `header`, and the first group of its RE2 pattern is the value:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
client.endpoints.update(
|
|
96
|
+
endpoint.slug,
|
|
97
|
+
email_fields=[{"name": "invoice", "from": "subject", "pattern": r"(INV-\d+)"}],
|
|
98
|
+
)
|
|
99
|
+
email = client.emails.wait_for(endpoint.slug, tag=run)
|
|
100
|
+
email.email_fields # {"invoice": "INV-2041"}, None for a field that found nothing
|
|
101
|
+
email.reply_text # what a reply says above the quoted message, without the signature
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Forwarding templates read the fields as `{{ fields.invoice }}`. `email_field_issues()` checks a list before you save it (the API checks the patterns themselves).
|
|
105
|
+
|
|
106
|
+
To test a handler you wrote for an inbound mail provider, render a captured email in that provider's shape and post it there:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
rendered = client.emails.render(email.id, format="postmark")
|
|
110
|
+
response = client.send_to(
|
|
111
|
+
"http://localhost:8000/inbound/postmark", body=rendered.body, headers=rendered.headers
|
|
112
|
+
)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`format` is `"postmark"`, `"sendgrid"`, `"mailgun"` (pass `mailgun_signing_key` to sign the form as Mailgun does) or `"cloudmailin"`, or `"json"` for the `email.received` JSON.
|
|
116
|
+
|
|
90
117
|
## Send signed webhooks to your handler
|
|
91
118
|
|
|
92
119
|
Test your own webhook handler, on localhost or anywhere else, with requests signed the way the provider signs them:
|
|
@@ -118,7 +145,7 @@ valid = verify_signature(
|
|
|
118
145
|
|
|
119
146
|
Twilio, Square and HubSpot also need `url`; HubSpot also `method`. Discord takes `public_key`. One function per provider is in `webhooks_cc.verify`.
|
|
120
147
|
|
|
121
|
-
When webhooks.cc forwards requests or email to your server, check that they came from it with the
|
|
148
|
+
When webhooks.cc forwards requests or email to your server, check that they came from it with the destination's signing secret (`client.destinations.secret(slug, destination_id)`, or `client.forwarding.secret(slug)` for the endpoint's primary destination):
|
|
122
149
|
|
|
123
150
|
```python
|
|
124
151
|
from webhooks_cc import WebhookVerificationError, verify_forwarded_email
|
|
@@ -127,10 +154,53 @@ try:
|
|
|
127
154
|
event = verify_forwarded_email(body, headers, secret)
|
|
128
155
|
except WebhookVerificationError as error:
|
|
129
156
|
return 400 if error.code == "invalid_payload" else 401
|
|
130
|
-
print(event["data"]["subject"])
|
|
157
|
+
print(event["data"]["subject"], event["data"]["replyText"], event["data"].get("fields"))
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`verify_forwarded_request()` checks a request forwarded as JSON, `verify_forwarded_json()` a destination's own JSON (it returns the parsed body, whatever its shape), and `verify_relayed_request()` one relayed as it was received.
|
|
161
|
+
|
|
162
|
+
## Forwarding destinations
|
|
163
|
+
|
|
164
|
+
An endpoint forwards what it captures to up to 10 destinations, each with its own kinds, filter, format, headers, retries, throttle, delay and signing secret:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
destination = client.destinations.create(
|
|
168
|
+
endpoint.slug,
|
|
169
|
+
url="https://hooks.example.com/invoices",
|
|
170
|
+
email=False,
|
|
171
|
+
filter={
|
|
172
|
+
"conditions": [{"field": "body_path", "path": "type", "op": "eq", "value": "invoice.paid"}]
|
|
173
|
+
},
|
|
174
|
+
format="template",
|
|
175
|
+
body_template={
|
|
176
|
+
"invoice": "{{ json.data.object.id }}",
|
|
177
|
+
"amount": "{{ json.data.object.amount_paid }}",
|
|
178
|
+
},
|
|
179
|
+
)
|
|
180
|
+
preview = client.destinations.preview(endpoint.slug, destination_id=destination.id)
|
|
181
|
+
print(preview.filter_passes, preview.delivery.body if preview.delivery else preview.error)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Filters take the conditions of response rules, plus `kind`, `email` (with `name` `subject`, `from`, `to`, `text` or `tag`), `spam_score`, `email_auth` (with `name` `spf`, `dkim` or `dmarc`) and `signature`. `format` may also be an inbound mail provider's shape (`"postmark"`, `"sendgrid"`, `"mailgun"` or `"cloudmailin"`): emails go out as that provider posts them and HTTP requests as received. `mailgun_signing_key` signs the Mailgun form; it is never returned, and `has_mailgun_signing_key` says whether one is saved. `preview()` is a dry run: pass a saved `destination_id`, unsaved fields in `destination` (the names `create()` takes), or both. `update()` changes only the arguments you pass, `test()` sends one delivery now and reports the answer, and `list()` (with `summary=True` for the last 24 hours), `get()`, `delete()`, `reorder()`, `secret()` and `rotate_secret()` do what they say.
|
|
185
|
+
|
|
186
|
+
## Mock responses and response rules
|
|
187
|
+
|
|
188
|
+
An endpoint answers with `mock_response` instead of a plain 200 OK, or with the first of its `response_rules` whose conditions match the request (method, path, header, query, body, body_path, content_type or size). Bodies and header values may hold `{{ placeholders }}` filled in from each request, such as `{{ json.id }}`. Check what a sender would get back without sending anything:
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
preview = client.endpoints.preview_response(
|
|
192
|
+
endpoint.slug,
|
|
193
|
+
response_rules=[
|
|
194
|
+
{
|
|
195
|
+
"conditions": [{"field": "body_path", "path": "type", "op": "eq", "value": "ping"}],
|
|
196
|
+
"response": {"status": 202, "body": '{"pong": "{{ json.id }}"}', "headers": {}},
|
|
197
|
+
}
|
|
198
|
+
],
|
|
199
|
+
)
|
|
200
|
+
print(preview.rule, preview.response.status if preview.response else 200)
|
|
131
201
|
```
|
|
132
202
|
|
|
133
|
-
|
|
203
|
+
It runs against the endpoint's newest HTTP request unless you pass `request_id`, and uses the saved settings unless you pass `mock_response` or `response_rules` (`None` tries none at all). `client.endpoints.update(slug, response_rules=[...])` saves them.
|
|
134
204
|
|
|
135
205
|
## Stream requests as they arrive
|
|
136
206
|
|
|
@@ -141,7 +211,7 @@ for request in client.requests.subscribe(endpoint.slug, timeout="10m", reconnect
|
|
|
141
211
|
|
|
142
212
|
## Errors
|
|
143
213
|
|
|
144
|
-
API errors raise `WebhooksCCError` with a `status_code`, or one of its subclasses: `UnauthorizedError` (401), `NotFoundError` (404), `RateLimitError` (429, with `retry_after`) and `TimeoutError`, which is also Python's built-in `TimeoutError`. Network failures raise httpx's exceptions. Retries are off unless you pass `retry=RetryOptions(max_attempts=3)`.
|
|
214
|
+
API errors raise `WebhooksCCError` with a `status_code`, or one of its subclasses: `UnauthorizedError` (401), `NotFoundError` (404), `RateLimitError` (429, with `retry_after`) and `TimeoutError`, which is also Python's built-in `TimeoutError`. Network failures raise httpx's exceptions. Retries are off unless you pass `retry=RetryOptions(max_attempts=3)`. They repeat GET, PUT and DELETE calls after a network error, a timeout or a status in `retry_on` (429, 500, 502, 503 and 504 by default), and any call after a 429; a POST or PATCH that failed any other way may have gone through, so it is not repeated, and neither is `requests.clear()` without `before`.
|
|
145
215
|
|
|
146
216
|
## Same behaviour as the TypeScript SDK
|
|
147
217
|
|
|
@@ -29,7 +29,7 @@ with WebhooksCC() as client:
|
|
|
29
29
|
|
|
30
30
|
Durations take `"500ms"`, `"30s"`, `"5m"`, `"2h"`, `"7d"`, a `timedelta`, or a number of milliseconds (the unit every webhooks.cc SDK uses, so prefer a string where you mean seconds). Times such as `since` and `Request.received_at` are milliseconds since the epoch.
|
|
31
31
|
|
|
32
|
-
Matchers combine with `match_all()` and `match_any()`, and any function that takes a `Request` and returns a bool works too: `match_method`, `match_path` (globs
|
|
32
|
+
Matchers combine with `match_all()` and `match_any()`, and any function that takes a `Request` and returns a bool works too: `match_method`, `match_path` (globs: `*` within a segment, `**` across segments, `?` for one character), `match_header`, `match_query_param`, `match_body_path`, `match_body_subset`, `match_content_type`, `match_verified`, `match_unverified` and `match_email`.
|
|
33
33
|
|
|
34
34
|
## pytest
|
|
35
35
|
|
|
@@ -63,6 +63,33 @@ link = extract_link(email) # "https://app.example.com/confirm?token=...
|
|
|
63
63
|
|
|
64
64
|
`email.email` holds the parsed message: `subject`, `from_`, `to`, `text`, `html`, attachments, and the SPF, DKIM and DMARC results.
|
|
65
65
|
|
|
66
|
+
## Email fields, reply text and provider shapes
|
|
67
|
+
|
|
68
|
+
An endpoint can pick up to 20 named fields out of every email it captures. Each reads the `subject`, `text`, `html`, `from`, `to` or one `header`, and the first group of its RE2 pattern is the value:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
client.endpoints.update(
|
|
72
|
+
endpoint.slug,
|
|
73
|
+
email_fields=[{"name": "invoice", "from": "subject", "pattern": r"(INV-\d+)"}],
|
|
74
|
+
)
|
|
75
|
+
email = client.emails.wait_for(endpoint.slug, tag=run)
|
|
76
|
+
email.email_fields # {"invoice": "INV-2041"}, None for a field that found nothing
|
|
77
|
+
email.reply_text # what a reply says above the quoted message, without the signature
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Forwarding templates read the fields as `{{ fields.invoice }}`. `email_field_issues()` checks a list before you save it (the API checks the patterns themselves).
|
|
81
|
+
|
|
82
|
+
To test a handler you wrote for an inbound mail provider, render a captured email in that provider's shape and post it there:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
rendered = client.emails.render(email.id, format="postmark")
|
|
86
|
+
response = client.send_to(
|
|
87
|
+
"http://localhost:8000/inbound/postmark", body=rendered.body, headers=rendered.headers
|
|
88
|
+
)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`format` is `"postmark"`, `"sendgrid"`, `"mailgun"` (pass `mailgun_signing_key` to sign the form as Mailgun does) or `"cloudmailin"`, or `"json"` for the `email.received` JSON.
|
|
92
|
+
|
|
66
93
|
## Send signed webhooks to your handler
|
|
67
94
|
|
|
68
95
|
Test your own webhook handler, on localhost or anywhere else, with requests signed the way the provider signs them:
|
|
@@ -94,7 +121,7 @@ valid = verify_signature(
|
|
|
94
121
|
|
|
95
122
|
Twilio, Square and HubSpot also need `url`; HubSpot also `method`. Discord takes `public_key`. One function per provider is in `webhooks_cc.verify`.
|
|
96
123
|
|
|
97
|
-
When webhooks.cc forwards requests or email to your server, check that they came from it with the
|
|
124
|
+
When webhooks.cc forwards requests or email to your server, check that they came from it with the destination's signing secret (`client.destinations.secret(slug, destination_id)`, or `client.forwarding.secret(slug)` for the endpoint's primary destination):
|
|
98
125
|
|
|
99
126
|
```python
|
|
100
127
|
from webhooks_cc import WebhookVerificationError, verify_forwarded_email
|
|
@@ -103,10 +130,53 @@ try:
|
|
|
103
130
|
event = verify_forwarded_email(body, headers, secret)
|
|
104
131
|
except WebhookVerificationError as error:
|
|
105
132
|
return 400 if error.code == "invalid_payload" else 401
|
|
106
|
-
print(event["data"]["subject"])
|
|
133
|
+
print(event["data"]["subject"], event["data"]["replyText"], event["data"].get("fields"))
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`verify_forwarded_request()` checks a request forwarded as JSON, `verify_forwarded_json()` a destination's own JSON (it returns the parsed body, whatever its shape), and `verify_relayed_request()` one relayed as it was received.
|
|
137
|
+
|
|
138
|
+
## Forwarding destinations
|
|
139
|
+
|
|
140
|
+
An endpoint forwards what it captures to up to 10 destinations, each with its own kinds, filter, format, headers, retries, throttle, delay and signing secret:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
destination = client.destinations.create(
|
|
144
|
+
endpoint.slug,
|
|
145
|
+
url="https://hooks.example.com/invoices",
|
|
146
|
+
email=False,
|
|
147
|
+
filter={
|
|
148
|
+
"conditions": [{"field": "body_path", "path": "type", "op": "eq", "value": "invoice.paid"}]
|
|
149
|
+
},
|
|
150
|
+
format="template",
|
|
151
|
+
body_template={
|
|
152
|
+
"invoice": "{{ json.data.object.id }}",
|
|
153
|
+
"amount": "{{ json.data.object.amount_paid }}",
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
preview = client.destinations.preview(endpoint.slug, destination_id=destination.id)
|
|
157
|
+
print(preview.filter_passes, preview.delivery.body if preview.delivery else preview.error)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Filters take the conditions of response rules, plus `kind`, `email` (with `name` `subject`, `from`, `to`, `text` or `tag`), `spam_score`, `email_auth` (with `name` `spf`, `dkim` or `dmarc`) and `signature`. `format` may also be an inbound mail provider's shape (`"postmark"`, `"sendgrid"`, `"mailgun"` or `"cloudmailin"`): emails go out as that provider posts them and HTTP requests as received. `mailgun_signing_key` signs the Mailgun form; it is never returned, and `has_mailgun_signing_key` says whether one is saved. `preview()` is a dry run: pass a saved `destination_id`, unsaved fields in `destination` (the names `create()` takes), or both. `update()` changes only the arguments you pass, `test()` sends one delivery now and reports the answer, and `list()` (with `summary=True` for the last 24 hours), `get()`, `delete()`, `reorder()`, `secret()` and `rotate_secret()` do what they say.
|
|
161
|
+
|
|
162
|
+
## Mock responses and response rules
|
|
163
|
+
|
|
164
|
+
An endpoint answers with `mock_response` instead of a plain 200 OK, or with the first of its `response_rules` whose conditions match the request (method, path, header, query, body, body_path, content_type or size). Bodies and header values may hold `{{ placeholders }}` filled in from each request, such as `{{ json.id }}`. Check what a sender would get back without sending anything:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
preview = client.endpoints.preview_response(
|
|
168
|
+
endpoint.slug,
|
|
169
|
+
response_rules=[
|
|
170
|
+
{
|
|
171
|
+
"conditions": [{"field": "body_path", "path": "type", "op": "eq", "value": "ping"}],
|
|
172
|
+
"response": {"status": 202, "body": '{"pong": "{{ json.id }}"}', "headers": {}},
|
|
173
|
+
}
|
|
174
|
+
],
|
|
175
|
+
)
|
|
176
|
+
print(preview.rule, preview.response.status if preview.response else 200)
|
|
107
177
|
```
|
|
108
178
|
|
|
109
|
-
|
|
179
|
+
It runs against the endpoint's newest HTTP request unless you pass `request_id`, and uses the saved settings unless you pass `mock_response` or `response_rules` (`None` tries none at all). `client.endpoints.update(slug, response_rules=[...])` saves them.
|
|
110
180
|
|
|
111
181
|
## Stream requests as they arrive
|
|
112
182
|
|
|
@@ -117,7 +187,7 @@ for request in client.requests.subscribe(endpoint.slug, timeout="10m", reconnect
|
|
|
117
187
|
|
|
118
188
|
## Errors
|
|
119
189
|
|
|
120
|
-
API errors raise `WebhooksCCError` with a `status_code`, or one of its subclasses: `UnauthorizedError` (401), `NotFoundError` (404), `RateLimitError` (429, with `retry_after`) and `TimeoutError`, which is also Python's built-in `TimeoutError`. Network failures raise httpx's exceptions. Retries are off unless you pass `retry=RetryOptions(max_attempts=3)`.
|
|
190
|
+
API errors raise `WebhooksCCError` with a `status_code`, or one of its subclasses: `UnauthorizedError` (401), `NotFoundError` (404), `RateLimitError` (429, with `retry_after`) and `TimeoutError`, which is also Python's built-in `TimeoutError`. Network failures raise httpx's exceptions. Retries are off unless you pass `retry=RetryOptions(max_attempts=3)`. They repeat GET, PUT and DELETE calls after a network error, a timeout or a status in `retry_on` (429, 500, 502, 503 and 504 by default), and any call after a 429; a POST or PATCH that failed any other way may have gone through, so it is not repeated, and neither is `requests.clear()` without `before`.
|
|
121
191
|
|
|
122
192
|
## Same behaviour as the TypeScript SDK
|
|
123
193
|
|
|
@@ -16,13 +16,23 @@ your own handlers, and verify provider and forwarding signatures.
|
|
|
16
16
|
Every signature check for each provider is in :mod:`webhooks_cc.verify`.
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
|
-
__version__ = "0.
|
|
19
|
+
__version__ = "0.2.0"
|
|
20
20
|
|
|
21
|
-
from .client import
|
|
21
|
+
from .client import (
|
|
22
|
+
Destinations,
|
|
23
|
+
Emails,
|
|
24
|
+
Endpoints,
|
|
25
|
+
Forwarding,
|
|
26
|
+
Requests,
|
|
27
|
+
RetryOptions,
|
|
28
|
+
WebhooksCC,
|
|
29
|
+
)
|
|
22
30
|
from .durations import Duration, parse_duration
|
|
23
31
|
from .email import (
|
|
24
32
|
DEFAULT_EMAIL_DOMAIN,
|
|
33
|
+
INBOUND_EMAIL_FORMATS,
|
|
25
34
|
email_address,
|
|
35
|
+
email_field_issues,
|
|
26
36
|
extract_code,
|
|
27
37
|
extract_link,
|
|
28
38
|
is_valid_email_tag,
|
|
@@ -53,14 +63,31 @@ from .matchers import (
|
|
|
53
63
|
)
|
|
54
64
|
from .models import (
|
|
55
65
|
BuiltRequest,
|
|
66
|
+
DeliveryCounts,
|
|
67
|
+
Destination,
|
|
68
|
+
DestinationFilter,
|
|
69
|
+
DestinationFormat,
|
|
70
|
+
DestinationHeader,
|
|
71
|
+
DestinationPreview,
|
|
72
|
+
DestinationRequest,
|
|
73
|
+
DestinationSummary,
|
|
74
|
+
DestinationTestResult,
|
|
56
75
|
EmailAddress,
|
|
57
76
|
EmailAttachment,
|
|
58
77
|
EmailCapture,
|
|
59
78
|
EmailExtracts,
|
|
79
|
+
EmailField,
|
|
60
80
|
Endpoint,
|
|
61
81
|
ExtractedLink,
|
|
82
|
+
PreviewedDelivery,
|
|
83
|
+
PreviewedResponse,
|
|
84
|
+
PreviewRequest,
|
|
85
|
+
PreviewRule,
|
|
86
|
+
RenderedEmail,
|
|
62
87
|
Request,
|
|
63
88
|
RequestPage,
|
|
89
|
+
ResponsePreview,
|
|
90
|
+
SenderTime,
|
|
64
91
|
SendTestEmailResult,
|
|
65
92
|
TeamShare,
|
|
66
93
|
UsageInfo,
|
|
@@ -76,6 +103,7 @@ from .testing import capture_during, capture_email_during, temporary_endpoint
|
|
|
76
103
|
from .verify import (
|
|
77
104
|
RelayedRequestInfo,
|
|
78
105
|
verify_forwarded_email,
|
|
106
|
+
verify_forwarded_json,
|
|
79
107
|
verify_forwarded_request,
|
|
80
108
|
verify_relayed_request,
|
|
81
109
|
verify_signature,
|
|
@@ -83,15 +111,27 @@ from .verify import (
|
|
|
83
111
|
|
|
84
112
|
__all__ = [
|
|
85
113
|
"DEFAULT_EMAIL_DOMAIN",
|
|
114
|
+
"INBOUND_EMAIL_FORMATS",
|
|
86
115
|
"TEMPLATE_METADATA",
|
|
87
116
|
"TEMPLATE_PROVIDERS",
|
|
88
117
|
"VERIFY_PROVIDERS",
|
|
89
118
|
"BuiltRequest",
|
|
119
|
+
"DeliveryCounts",
|
|
120
|
+
"Destination",
|
|
121
|
+
"DestinationFilter",
|
|
122
|
+
"DestinationFormat",
|
|
123
|
+
"DestinationHeader",
|
|
124
|
+
"DestinationPreview",
|
|
125
|
+
"DestinationRequest",
|
|
126
|
+
"DestinationSummary",
|
|
127
|
+
"DestinationTestResult",
|
|
128
|
+
"Destinations",
|
|
90
129
|
"Duration",
|
|
91
130
|
"EmailAddress",
|
|
92
131
|
"EmailAttachment",
|
|
93
132
|
"EmailCapture",
|
|
94
133
|
"EmailExtracts",
|
|
134
|
+
"EmailField",
|
|
95
135
|
"Emails",
|
|
96
136
|
"Endpoint",
|
|
97
137
|
"Endpoints",
|
|
@@ -99,13 +139,20 @@ __all__ = [
|
|
|
99
139
|
"Forwarding",
|
|
100
140
|
"Matcher",
|
|
101
141
|
"NotFoundError",
|
|
142
|
+
"PreviewRequest",
|
|
143
|
+
"PreviewRule",
|
|
144
|
+
"PreviewedDelivery",
|
|
145
|
+
"PreviewedResponse",
|
|
102
146
|
"RateLimitError",
|
|
103
147
|
"RelayedRequestInfo",
|
|
148
|
+
"RenderedEmail",
|
|
104
149
|
"Request",
|
|
105
150
|
"RequestPage",
|
|
106
151
|
"Requests",
|
|
152
|
+
"ResponsePreview",
|
|
107
153
|
"RetryOptions",
|
|
108
154
|
"SendTestEmailResult",
|
|
155
|
+
"SenderTime",
|
|
109
156
|
"TeamShare",
|
|
110
157
|
"TemplateProviderInfo",
|
|
111
158
|
"TimeoutError",
|
|
@@ -119,6 +166,7 @@ __all__ = [
|
|
|
119
166
|
"capture_during",
|
|
120
167
|
"capture_email_during",
|
|
121
168
|
"email_address",
|
|
169
|
+
"email_field_issues",
|
|
122
170
|
"extract_code",
|
|
123
171
|
"extract_link",
|
|
124
172
|
"is_email_request",
|
|
@@ -138,6 +186,7 @@ __all__ = [
|
|
|
138
186
|
"parse_duration",
|
|
139
187
|
"temporary_endpoint",
|
|
140
188
|
"verify_forwarded_email",
|
|
189
|
+
"verify_forwarded_json",
|
|
141
190
|
"verify_forwarded_request",
|
|
142
191
|
"verify_relayed_request",
|
|
143
192
|
"verify_signature",
|
|
@@ -13,9 +13,11 @@ import hmac
|
|
|
13
13
|
import json
|
|
14
14
|
import math
|
|
15
15
|
import re
|
|
16
|
-
from collections.abc import Mapping, Sequence
|
|
16
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
17
17
|
from typing import Any, Literal
|
|
18
18
|
|
|
19
|
+
from ._js import js_trim
|
|
20
|
+
|
|
19
21
|
HashName = Literal["sha256", "sha1"]
|
|
20
22
|
|
|
21
23
|
_BASE64_ALPHABET = re.compile(r"^[A-Za-z0-9+/]*$")
|
|
@@ -69,7 +71,7 @@ def from_base64(value: str) -> bytes:
|
|
|
69
71
|
|
|
70
72
|
|
|
71
73
|
def hex_to_bytes(value: str) -> bytes:
|
|
72
|
-
normalized = value
|
|
74
|
+
normalized = js_trim(value)
|
|
73
75
|
if not _HEX.match(normalized) or len(normalized) % 2 != 0:
|
|
74
76
|
raise ValueError("Expected a hex-encoded value")
|
|
75
77
|
return bytes.fromhex(normalized)
|
|
@@ -217,7 +219,9 @@ def js_string(value: Any) -> str:
|
|
|
217
219
|
def js_json(value: Any) -> str:
|
|
218
220
|
"""Serialize like JavaScript's ``JSON.stringify(value)``.
|
|
219
221
|
|
|
220
|
-
Compact, keys in
|
|
222
|
+
Compact, keys in the order a JavaScript object keeps them (array-index keys
|
|
223
|
+
such as ``"2"`` first, ascending, then the rest as inserted), non-ASCII
|
|
224
|
+
characters left as they are.
|
|
221
225
|
"""
|
|
222
226
|
parts: list[str] = []
|
|
223
227
|
_write_json(value, parts)
|
|
@@ -238,11 +242,11 @@ def _write_json(value: Any, out: list[str]) -> None:
|
|
|
238
242
|
elif isinstance(value, Mapping):
|
|
239
243
|
out.append("{")
|
|
240
244
|
first = True
|
|
241
|
-
for key, item in value.items():
|
|
245
|
+
for key, item in _js_key_order(value.items()):
|
|
242
246
|
if not first:
|
|
243
247
|
out.append(",")
|
|
244
248
|
first = False
|
|
245
|
-
out.append(json.dumps(
|
|
249
|
+
out.append(json.dumps(key, ensure_ascii=False))
|
|
246
250
|
out.append(":")
|
|
247
251
|
_write_json(item, out)
|
|
248
252
|
out.append("}")
|
|
@@ -257,6 +261,22 @@ def _write_json(value: Any, out: list[str]) -> None:
|
|
|
257
261
|
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
|
258
262
|
|
|
259
263
|
|
|
264
|
+
def _is_array_index(key: str) -> bool:
|
|
265
|
+
"""Whether a JavaScript object treats ``key`` as an array index (0 to 2**32 - 2)."""
|
|
266
|
+
if not (key.isascii() and key.isdigit()) or (len(key) > 1 and key[0] == "0"):
|
|
267
|
+
return False
|
|
268
|
+
return int(key) < 2**32 - 1
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _js_key_order(items: Iterable[tuple[Any, Any]]) -> list[tuple[str, Any]]:
|
|
272
|
+
"""Members in the order a JavaScript object enumerates them."""
|
|
273
|
+
members = [(str(key), item) for key, item in items]
|
|
274
|
+
indexes = sorted((m for m in members if _is_array_index(m[0])), key=lambda m: int(m[0]))
|
|
275
|
+
if not indexes:
|
|
276
|
+
return members
|
|
277
|
+
return indexes + [m for m in members if not _is_array_index(m[0])]
|
|
278
|
+
|
|
279
|
+
|
|
260
280
|
def json_body(value: Any) -> str:
|
|
261
281
|
"""A request body: strings are sent as they are, anything else as JSON."""
|
|
262
282
|
return value if isinstance(value, str) else js_json(value)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Text read the way JavaScript reads it.
|
|
2
|
+
|
|
3
|
+
The TypeScript SDK is the reference, so where it trims a string, reads a number
|
|
4
|
+
or matches ``\\s`` and ``\\d``, this port must treat the same characters the same
|
|
5
|
+
way. Python's own rules differ: ``str.strip()`` and ``\\s`` also take
|
|
6
|
+
``\\x1c``-``\\x1f`` and ``\\x85`` but not ``\\ufeff``, and ``\\d`` and ``float()``
|
|
7
|
+
accept digits from every script.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import re
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
# What String.prototype.trim(), Number() and a regular expression's \s treat as whitespace.
|
|
18
|
+
JS_WHITESPACE = "".join(
|
|
19
|
+
chr(code)
|
|
20
|
+
for code in (
|
|
21
|
+
*(0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20, 0xA0, 0x1680),
|
|
22
|
+
*range(0x2000, 0x200B),
|
|
23
|
+
*(0x2028, 0x2029, 0x202F, 0x205F, 0x3000, 0xFEFF),
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# A regular-expression class equal to JavaScript's \s.
|
|
28
|
+
JS_SPACE = f"[{re.escape(JS_WHITESPACE)}]"
|
|
29
|
+
|
|
30
|
+
_JS_DECIMAL = re.compile(r"[+-]?(?:Infinity|(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?)")
|
|
31
|
+
# One digit class per base: int() would also take a second prefix ("0x0x10").
|
|
32
|
+
_JS_RADIX = re.compile(r"0(?:[xX]([0-9a-fA-F]+)|[oO]([0-7]+)|[bB]([01]+))")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def js_trim(value: str) -> str:
|
|
36
|
+
"""JavaScript's ``value.trim()``."""
|
|
37
|
+
return value.strip(JS_WHITESPACE)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def js_to_number(value: str | None) -> float:
|
|
41
|
+
"""JavaScript's ``Number(value)`` for a string: NaN when it is not a number, 0 when blank."""
|
|
42
|
+
if value is None:
|
|
43
|
+
return math.nan
|
|
44
|
+
text = js_trim(value)
|
|
45
|
+
if text == "":
|
|
46
|
+
return 0.0
|
|
47
|
+
if _JS_DECIMAL.fullmatch(text):
|
|
48
|
+
return float(text.replace("Infinity", "inf"))
|
|
49
|
+
radix_match = _JS_RADIX.fullmatch(text)
|
|
50
|
+
if radix_match:
|
|
51
|
+
hex_digits, octal_digits, binary_digits = radix_match.groups()
|
|
52
|
+
if hex_digits is not None:
|
|
53
|
+
return float(int(hex_digits, 16))
|
|
54
|
+
if octal_digits is not None:
|
|
55
|
+
return float(int(octal_digits, 8))
|
|
56
|
+
return float(int(binary_digits, 2))
|
|
57
|
+
return math.nan
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _reject_constant(name: str) -> Any:
|
|
61
|
+
raise ValueError(f"{name} is not JSON")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def json_parse(text: str | bytes) -> Any:
|
|
65
|
+
"""JavaScript's ``JSON.parse``: ``json.loads`` without NaN and Infinity, which it refuses."""
|
|
66
|
+
return json.loads(text, parse_constant=_reject_constant)
|