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.
Files changed (23) hide show
  1. {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/PKG-INFO +76 -6
  2. {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/README.md +75 -5
  3. {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/pyproject.toml +1 -1
  4. {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/__init__.py +51 -2
  5. {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/_crypto.py +25 -5
  6. webhooks_cc-0.2.0/src/webhooks_cc/_js.py +66 -0
  7. {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/client.py +534 -20
  8. {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/durations.py +4 -2
  9. webhooks_cc-0.2.0/src/webhooks_cc/email.py +151 -0
  10. {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/matchers.py +21 -11
  11. webhooks_cc-0.2.0/src/webhooks_cc/models.py +924 -0
  12. {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/verify.py +52 -68
  13. webhooks_cc-0.1.2/src/webhooks_cc/email.py +0 -78
  14. webhooks_cc-0.1.2/src/webhooks_cc/models.py +0 -403
  15. {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/.gitignore +0 -0
  16. {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/LICENSE +0 -0
  17. {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/_sentinel.py +0 -0
  18. {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/_sse.py +0 -0
  19. {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/errors.py +0 -0
  20. {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/py.typed +0 -0
  21. {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/pytest_plugin.py +0 -0
  22. {webhooks_cc-0.1.2 → webhooks_cc-0.2.0}/src/webhooks_cc/templates.py +0 -0
  23. {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.1.2
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 with `*` and `**`), `match_header`, `match_query_param`, `match_body_path`, `match_body_subset`, `match_content_type`, `match_verified`, `match_unverified` and `match_email`.
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 endpoint's forwarding secret (`client.forwarding.secret(slug)`):
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
- `verify_forwarded_request()` checks a request forwarded as JSON, and `verify_relayed_request()` one relayed as it was received.
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 with `*` and `**`), `match_header`, `match_query_param`, `match_body_path`, `match_body_subset`, `match_content_type`, `match_verified`, `match_unverified` and `match_email`.
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 endpoint's forwarding secret (`client.forwarding.secret(slug)`):
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
- `verify_forwarded_request()` checks a request forwarded as JSON, and `verify_relayed_request()` one relayed as it was received.
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webhooks-cc"
3
- version = "0.1.2"
3
+ version = "0.2.0"
4
4
  description = "Python SDK for webhooks.cc: capture, wait for, sign and verify webhooks and test emails"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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.1.2"
19
+ __version__ = "0.2.0"
20
20
 
21
- from .client import Emails, Endpoints, Forwarding, Requests, RetryOptions, WebhooksCC
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.strip()
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 their order, non-ASCII characters left as they are.
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(str(key), ensure_ascii=False))
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)