mailfixture 0.5.0__tar.gz → 0.6.2__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.
- {mailfixture-0.5.0 → mailfixture-0.6.2}/LICENSE +1 -1
- mailfixture-0.5.0/README.md → mailfixture-0.6.2/PKG-INFO +36 -0
- mailfixture-0.5.0/PKG-INFO → mailfixture-0.6.2/README.md +18 -18
- {mailfixture-0.5.0 → mailfixture-0.6.2}/pyproject.toml +3 -3
- {mailfixture-0.5.0 → mailfixture-0.6.2}/src/mailfixture/__init__.py +272 -21
- {mailfixture-0.5.0 → mailfixture-0.6.2}/tests/test_client.py +171 -1
- {mailfixture-0.5.0 → mailfixture-0.6.2}/.gitignore +0 -0
- {mailfixture-0.5.0 → mailfixture-0.6.2}/src/mailfixture/py.typed +0 -0
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mailfixture
|
|
3
|
+
Version: 0.6.2
|
|
4
|
+
Summary: Email, SMS, and webhook fixtures for test suites — programmatic inboxes, capture hooks, long-polling, and OTP extraction.
|
|
5
|
+
Project-URL: Homepage, https://mailfixture.com
|
|
6
|
+
Project-URL: Documentation, https://mailfixture.com/docs
|
|
7
|
+
Author-email: MailFixture <support@mailfixture.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: e2e,email,fixtures,otp,playwright,pytest,sms,testing,webhook,webhook-capture,webhook-testing
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Testing
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
1
19
|
# mailfixture
|
|
2
20
|
|
|
3
21
|
Email fixtures for your test suite. Create a programmatic inbox in one call,
|
|
@@ -39,6 +57,17 @@ link = inbox.wait_for_link(kind="verify", timeout=30)
|
|
|
39
57
|
page.goto(link.url)
|
|
40
58
|
```
|
|
41
59
|
|
|
60
|
+
Capturing a webhook your app sends:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
hook = mfx.create_hook(label="checkout", ttl_seconds=900)
|
|
64
|
+
configure_app_webhook(hook.ingest_url) # your test setup
|
|
65
|
+
trigger_checkout()
|
|
66
|
+
request = hook.wait_for_request(match="body:order.created")
|
|
67
|
+
assert request.method == "POST"
|
|
68
|
+
hook.delete()
|
|
69
|
+
```
|
|
70
|
+
|
|
42
71
|
Everything on the wait helpers is server-side long-polling: the request is
|
|
43
72
|
held open until a matching message arrives (or the timeout passes, raising
|
|
44
73
|
`mailfixture.MailFixtureTimeout`).
|
|
@@ -54,12 +83,19 @@ held open until a matching message arrives (or the timeout passes, raising
|
|
|
54
83
|
| `inbox.messages(since=, match=, wait=)` / `clear()` / `delete()` | raw listing & lifecycle |
|
|
55
84
|
| `get_message(id)`, `get_otp(id)`, `get_links(id)` | direct message access |
|
|
56
85
|
| `download_attachment(message_id, index)` | decoded attachment bytes |
|
|
86
|
+
| `create_hook(label=, ttl_seconds=)`, `list_hooks()` | webhook capture URL and lifecycle handles |
|
|
87
|
+
| `hook.wait_for_request(match=, since=, timeout=)` | first matching captured request, including full headers and body |
|
|
88
|
+
| `hook.requests(...)`, `clear()`, `delete()` / `get_hook_request(id)` | capture listing, teardown, and direct detail access |
|
|
57
89
|
| `create_domain(fqdn)`, `verify_domain(id)`, `list_domains()`, `delete_domain(id)` | custom domains |
|
|
58
90
|
| `create_key(label=)`, `list_keys()`, `revoke_key(id)` | API keys |
|
|
59
91
|
|
|
60
92
|
`match` filters with `subject:`, `from:`, or `to:` prefixes; a bare term
|
|
61
93
|
matches the subject.
|
|
62
94
|
|
|
95
|
+
Capture-hook matching accepts `method:`, `path:`, and `body:`; a bare term
|
|
96
|
+
matches the first 32 KiB of request body bytes. Returned detail bodies are
|
|
97
|
+
complete.
|
|
98
|
+
|
|
63
99
|
Errors are raised as `mailfixture.MailFixtureError` carrying the API's
|
|
64
100
|
RFC 7807 `status` / `title` / `detail` fields.
|
|
65
101
|
|
|
@@ -1,21 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: mailfixture
|
|
3
|
-
Version: 0.5.0
|
|
4
|
-
Summary: Email fixtures for your test suite — programmatic inboxes, long-polling, first-class OTP and link extraction.
|
|
5
|
-
Project-URL: Homepage, https://mailfixture.com
|
|
6
|
-
Project-URL: Documentation, https://mailfixture.com/docs
|
|
7
|
-
Author-email: MailFixture <support@mailfixture.com>
|
|
8
|
-
License-Expression: MIT
|
|
9
|
-
License-File: LICENSE
|
|
10
|
-
Keywords: e2e,email,fixtures,otp,playwright,pytest,testing
|
|
11
|
-
Classifier: Development Status :: 4 - Beta
|
|
12
|
-
Classifier: Intended Audience :: Developers
|
|
13
|
-
Classifier: Programming Language :: Python :: 3
|
|
14
|
-
Classifier: Topic :: Software Development :: Testing
|
|
15
|
-
Classifier: Typing :: Typed
|
|
16
|
-
Requires-Python: >=3.9
|
|
17
|
-
Description-Content-Type: text/markdown
|
|
18
|
-
|
|
19
1
|
# mailfixture
|
|
20
2
|
|
|
21
3
|
Email fixtures for your test suite. Create a programmatic inbox in one call,
|
|
@@ -57,6 +39,17 @@ link = inbox.wait_for_link(kind="verify", timeout=30)
|
|
|
57
39
|
page.goto(link.url)
|
|
58
40
|
```
|
|
59
41
|
|
|
42
|
+
Capturing a webhook your app sends:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
hook = mfx.create_hook(label="checkout", ttl_seconds=900)
|
|
46
|
+
configure_app_webhook(hook.ingest_url) # your test setup
|
|
47
|
+
trigger_checkout()
|
|
48
|
+
request = hook.wait_for_request(match="body:order.created")
|
|
49
|
+
assert request.method == "POST"
|
|
50
|
+
hook.delete()
|
|
51
|
+
```
|
|
52
|
+
|
|
60
53
|
Everything on the wait helpers is server-side long-polling: the request is
|
|
61
54
|
held open until a matching message arrives (or the timeout passes, raising
|
|
62
55
|
`mailfixture.MailFixtureTimeout`).
|
|
@@ -72,12 +65,19 @@ held open until a matching message arrives (or the timeout passes, raising
|
|
|
72
65
|
| `inbox.messages(since=, match=, wait=)` / `clear()` / `delete()` | raw listing & lifecycle |
|
|
73
66
|
| `get_message(id)`, `get_otp(id)`, `get_links(id)` | direct message access |
|
|
74
67
|
| `download_attachment(message_id, index)` | decoded attachment bytes |
|
|
68
|
+
| `create_hook(label=, ttl_seconds=)`, `list_hooks()` | webhook capture URL and lifecycle handles |
|
|
69
|
+
| `hook.wait_for_request(match=, since=, timeout=)` | first matching captured request, including full headers and body |
|
|
70
|
+
| `hook.requests(...)`, `clear()`, `delete()` / `get_hook_request(id)` | capture listing, teardown, and direct detail access |
|
|
75
71
|
| `create_domain(fqdn)`, `verify_domain(id)`, `list_domains()`, `delete_domain(id)` | custom domains |
|
|
76
72
|
| `create_key(label=)`, `list_keys()`, `revoke_key(id)` | API keys |
|
|
77
73
|
|
|
78
74
|
`match` filters with `subject:`, `from:`, or `to:` prefixes; a bare term
|
|
79
75
|
matches the subject.
|
|
80
76
|
|
|
77
|
+
Capture-hook matching accepts `method:`, `path:`, and `body:`; a bare term
|
|
78
|
+
matches the first 32 KiB of request body bytes. Returned detail bodies are
|
|
79
|
+
complete.
|
|
80
|
+
|
|
81
81
|
Errors are raised as `mailfixture.MailFixtureError` carrying the API's
|
|
82
82
|
RFC 7807 `status` / `title` / `detail` fields.
|
|
83
83
|
|
|
@@ -4,14 +4,14 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mailfixture"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "Email fixtures for
|
|
7
|
+
version = "0.6.2"
|
|
8
|
+
description = "Email, SMS, and webhook fixtures for test suites — programmatic inboxes, capture hooks, long-polling, and OTP extraction."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
11
|
license-files = ["LICENSE"]
|
|
12
12
|
requires-python = ">=3.9"
|
|
13
13
|
authors = [{ name = "MailFixture", email = "support@mailfixture.com" }]
|
|
14
|
-
keywords = ["email", "testing", "otp", "fixtures", "e2e", "playwright", "pytest"]
|
|
14
|
+
keywords = ["email", "webhook", "webhook-testing", "webhook-capture", "sms", "testing", "otp", "fixtures", "e2e", "playwright", "pytest"]
|
|
15
15
|
classifiers = [
|
|
16
16
|
"Development Status :: 4 - Beta",
|
|
17
17
|
"Intended Audience :: Developers",
|
|
@@ -17,6 +17,7 @@ to tune and no flaky retry loop.
|
|
|
17
17
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
|
+
import base64
|
|
20
21
|
import hashlib
|
|
21
22
|
import hmac
|
|
22
23
|
import json
|
|
@@ -39,6 +40,10 @@ __all__ = [
|
|
|
39
40
|
"PhoneNumber",
|
|
40
41
|
"Sms",
|
|
41
42
|
"SmsSummary",
|
|
43
|
+
"Hook",
|
|
44
|
+
"HookHeader",
|
|
45
|
+
"HookRequest",
|
|
46
|
+
"HookRequestSummary",
|
|
42
47
|
"Otp",
|
|
43
48
|
"OtpCandidate",
|
|
44
49
|
"Link",
|
|
@@ -62,7 +67,7 @@ __all__ = [
|
|
|
62
67
|
"MailFixtureTimeout",
|
|
63
68
|
]
|
|
64
69
|
|
|
65
|
-
__version__ = "0.
|
|
70
|
+
__version__ = "0.6.2"
|
|
66
71
|
|
|
67
72
|
DEFAULT_BASE_URL = "https://api.mailfixture.com"
|
|
68
73
|
#: Server-side ceiling for one long-poll request; the client chains
|
|
@@ -325,7 +330,10 @@ class Message:
|
|
|
325
330
|
subject: Optional[str]
|
|
326
331
|
text_body: Optional[str]
|
|
327
332
|
html_body: Optional[str]
|
|
328
|
-
|
|
333
|
+
#: Every header in wire order as ``{"name": ..., "value": ...}`` dicts
|
|
334
|
+
#: (original casing, folded lines unfolded; names can repeat, e.g.
|
|
335
|
+
#: ``Received``) — look them up case-insensitively.
|
|
336
|
+
headers: List[Dict[str, str]]
|
|
329
337
|
extracted: Dict[str, Any]
|
|
330
338
|
attachments: List[Attachment]
|
|
331
339
|
raw_size: int
|
|
@@ -368,7 +376,7 @@ class Message:
|
|
|
368
376
|
subject=data.get("subject"),
|
|
369
377
|
text_body=data.get("text_body"),
|
|
370
378
|
html_body=data.get("html_body"),
|
|
371
|
-
headers=data.get("headers") or
|
|
379
|
+
headers=list(data.get("headers") or []),
|
|
372
380
|
extracted=data.get("extracted") or {},
|
|
373
381
|
attachments=[
|
|
374
382
|
Attachment(
|
|
@@ -444,6 +452,92 @@ class Sms:
|
|
|
444
452
|
)
|
|
445
453
|
|
|
446
454
|
|
|
455
|
+
@dataclass
|
|
456
|
+
class HookRequestSummary:
|
|
457
|
+
"""One request captured by a hook — metadata only; the body rides on
|
|
458
|
+
`HookRequest` (fetch it with `get_hook_request`)."""
|
|
459
|
+
|
|
460
|
+
id: str
|
|
461
|
+
hook_id: str
|
|
462
|
+
received_at: datetime
|
|
463
|
+
#: Uppercased HTTP verb as received.
|
|
464
|
+
method: str
|
|
465
|
+
#: Path + query after the token; "/" when the bare ingest URL was hit.
|
|
466
|
+
path: str
|
|
467
|
+
content_type: Optional[str]
|
|
468
|
+
body_size: int
|
|
469
|
+
#: Wire-format timestamp, used verbatim as the next `since` cursor.
|
|
470
|
+
received_at_raw: str = field(repr=False, default="")
|
|
471
|
+
|
|
472
|
+
@classmethod
|
|
473
|
+
def _from_json(cls, data: Dict[str, Any]) -> "HookRequestSummary":
|
|
474
|
+
return cls(
|
|
475
|
+
id=data["id"],
|
|
476
|
+
hook_id=data["hook_id"],
|
|
477
|
+
received_at=_parse_dt(data["received_at"]),
|
|
478
|
+
method=data["method"],
|
|
479
|
+
path=data["path"],
|
|
480
|
+
content_type=data.get("content_type"),
|
|
481
|
+
body_size=data["body_size"],
|
|
482
|
+
received_at_raw=data["received_at"],
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
@dataclass
|
|
487
|
+
class HookHeader:
|
|
488
|
+
name: str
|
|
489
|
+
value: str
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
@dataclass
|
|
493
|
+
class HookRequest:
|
|
494
|
+
"""A captured request in full: headers and body. Header names are
|
|
495
|
+
lowercased; duplicate values of one name keep their order, but the
|
|
496
|
+
relative order of different names is not preserved — don't assert
|
|
497
|
+
on it. `body` is the text rendering; when `body_is_utf8` is False
|
|
498
|
+
use `body_bytes` (decoded from `body_base64`) for the exact
|
|
499
|
+
bytes — signature checks need them."""
|
|
500
|
+
|
|
501
|
+
id: str
|
|
502
|
+
hook_id: str
|
|
503
|
+
received_at: datetime
|
|
504
|
+
method: str
|
|
505
|
+
path: str
|
|
506
|
+
headers: List[HookHeader]
|
|
507
|
+
content_type: Optional[str]
|
|
508
|
+
body: str
|
|
509
|
+
body_base64: Optional[str]
|
|
510
|
+
body_is_utf8: bool
|
|
511
|
+
body_size: int
|
|
512
|
+
remote_ip: Optional[str]
|
|
513
|
+
expires_at: datetime
|
|
514
|
+
|
|
515
|
+
@property
|
|
516
|
+
def body_bytes(self) -> bytes:
|
|
517
|
+
"""Exact body bytes (decodes `body_base64` when present)."""
|
|
518
|
+
if self.body_base64 is not None:
|
|
519
|
+
return base64.b64decode(self.body_base64)
|
|
520
|
+
return self.body.encode("utf-8")
|
|
521
|
+
|
|
522
|
+
@classmethod
|
|
523
|
+
def _from_json(cls, data: Dict[str, Any]) -> "HookRequest":
|
|
524
|
+
return cls(
|
|
525
|
+
id=data["id"],
|
|
526
|
+
hook_id=data["hook_id"],
|
|
527
|
+
received_at=_parse_dt(data["received_at"]),
|
|
528
|
+
method=data["method"],
|
|
529
|
+
path=data["path"],
|
|
530
|
+
headers=[HookHeader(h["name"], h["value"]) for h in data.get("headers", [])],
|
|
531
|
+
content_type=data.get("content_type"),
|
|
532
|
+
body=data["body"],
|
|
533
|
+
body_base64=data.get("body_base64"),
|
|
534
|
+
body_is_utf8=data["body_is_utf8"],
|
|
535
|
+
body_size=data["body_size"],
|
|
536
|
+
remote_ip=data.get("remote_ip"),
|
|
537
|
+
expires_at=_parse_dt(data["expires_at"]),
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
|
|
447
541
|
@dataclass
|
|
448
542
|
class InboxSummary:
|
|
449
543
|
id: str
|
|
@@ -747,6 +841,63 @@ class PhoneNumber:
|
|
|
747
841
|
self._client.release_phone_number(self.id)
|
|
748
842
|
|
|
749
843
|
|
|
844
|
+
@dataclass
|
|
845
|
+
class Hook:
|
|
846
|
+
"""A capture hook (webhook capture) plus the wait helper bound to it.
|
|
847
|
+
|
|
848
|
+
Point the app-under-test's webhook config at `ingest_url`, then
|
|
849
|
+
`wait_for_request()` to assert the webhook fired. Not to be confused
|
|
850
|
+
with the outbound `Webhook` resource (signed event delivery).
|
|
851
|
+
"""
|
|
852
|
+
|
|
853
|
+
id: str
|
|
854
|
+
#: The public URL to configure in the app under test.
|
|
855
|
+
ingest_url: str
|
|
856
|
+
#: Write capability embedded in the URL — treat like a secret.
|
|
857
|
+
token: str
|
|
858
|
+
label: Optional[str]
|
|
859
|
+
ttl_expires_at: Optional[datetime]
|
|
860
|
+
created_at: datetime
|
|
861
|
+
_client: "MailFixture" = field(repr=False, compare=False, default=None) # type: ignore[assignment]
|
|
862
|
+
|
|
863
|
+
@classmethod
|
|
864
|
+
def _from_json(cls, data: Dict[str, Any], client: "MailFixture") -> "Hook":
|
|
865
|
+
ttl = data.get("ttl_expires_at")
|
|
866
|
+
return cls(
|
|
867
|
+
id=data["id"],
|
|
868
|
+
ingest_url=data["ingest_url"],
|
|
869
|
+
token=data["token"],
|
|
870
|
+
label=data.get("label"),
|
|
871
|
+
ttl_expires_at=_parse_dt(ttl) if ttl else None,
|
|
872
|
+
created_at=_parse_dt(data["created_at"]),
|
|
873
|
+
_client=client,
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
def requests(
|
|
877
|
+
self,
|
|
878
|
+
since: Optional[str] = None,
|
|
879
|
+
match: Optional[str] = None,
|
|
880
|
+
wait: Optional[int] = None,
|
|
881
|
+
) -> List[HookRequestSummary]:
|
|
882
|
+
return self._client.list_hook_requests(self.id, since=since, match=match, wait=wait)
|
|
883
|
+
|
|
884
|
+
def wait_for_request(
|
|
885
|
+
self,
|
|
886
|
+
match: Optional[str] = None,
|
|
887
|
+
since: Optional[str] = None,
|
|
888
|
+
timeout: float = 45.0,
|
|
889
|
+
) -> HookRequest:
|
|
890
|
+
return self._client.wait_for_hook_request(
|
|
891
|
+
self.id, match=match, since=since, timeout=timeout
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
def clear(self) -> None:
|
|
895
|
+
self._client.clear_hook(self.id)
|
|
896
|
+
|
|
897
|
+
def delete(self) -> None:
|
|
898
|
+
self._client.delete_hook(self.id)
|
|
899
|
+
|
|
900
|
+
|
|
750
901
|
class MailFixture:
|
|
751
902
|
"""Synchronous MailFixture API client.
|
|
752
903
|
|
|
@@ -952,23 +1103,30 @@ class MailFixture:
|
|
|
952
1103
|
if not summaries and deadline - time.monotonic() <= 0:
|
|
953
1104
|
return
|
|
954
1105
|
|
|
955
|
-
def
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
match: Optional[str],
|
|
960
|
-
remaining: float,
|
|
961
|
-
) -> List[MessageSummary]:
|
|
962
|
-
"""One long-poll chunk, rate-limit aware."""
|
|
1106
|
+
def _poll_chunk(self, list_page, since, match, remaining):
|
|
1107
|
+
"""One long-poll chunk, rate-limit aware — the single body behind
|
|
1108
|
+
every wait surface (messages, SMS, hook requests), so the server-
|
|
1109
|
+
wait clamp and 429/Retry-After absorption cannot drift."""
|
|
963
1110
|
wait = max(0, min(math.ceil(remaining), MAX_SERVER_WAIT))
|
|
964
1111
|
try:
|
|
965
|
-
return
|
|
1112
|
+
return list_page(since=since, match=match, wait=wait)
|
|
966
1113
|
except MailFixtureError as err:
|
|
967
1114
|
if err.status == 429 and err.retry_after and err.retry_after < remaining:
|
|
968
1115
|
time.sleep(err.retry_after)
|
|
969
1116
|
return []
|
|
970
1117
|
raise
|
|
971
1118
|
|
|
1119
|
+
def _poll(
|
|
1120
|
+
self,
|
|
1121
|
+
inbox_id: str,
|
|
1122
|
+
since: Optional[str],
|
|
1123
|
+
match: Optional[str],
|
|
1124
|
+
remaining: float,
|
|
1125
|
+
) -> List[MessageSummary]:
|
|
1126
|
+
return self._poll_chunk(
|
|
1127
|
+
lambda **kw: self.list_messages(inbox_id, **kw), since, match, remaining
|
|
1128
|
+
)
|
|
1129
|
+
|
|
972
1130
|
# -- phone numbers (paid plans) ------------------------------------------
|
|
973
1131
|
|
|
974
1132
|
def create_phone_number(self) -> PhoneNumber:
|
|
@@ -1106,15 +1264,108 @@ class MailFixture:
|
|
|
1106
1264
|
match: Optional[str],
|
|
1107
1265
|
remaining: float,
|
|
1108
1266
|
) -> List[SmsSummary]:
|
|
1109
|
-
"""One long-poll chunk
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1267
|
+
"""One long-poll chunk over SMS — see `_poll_chunk`."""
|
|
1268
|
+
return self._poll_chunk(
|
|
1269
|
+
lambda **kw: self.list_sms(phone_number_id, **kw), since, match, remaining
|
|
1270
|
+
)
|
|
1271
|
+
|
|
1272
|
+
# -- capture hooks -------------------------------------------------------
|
|
1273
|
+
|
|
1274
|
+
def create_hook(
|
|
1275
|
+
self,
|
|
1276
|
+
label: Optional[str] = None,
|
|
1277
|
+
ttl_seconds: Optional[int] = None,
|
|
1278
|
+
) -> Hook:
|
|
1279
|
+
"""Create a capture hook (webhook capture): a public URL that
|
|
1280
|
+
records every HTTP request sent to it and always answers
|
|
1281
|
+
`200 {"ok":true}`. Point the app under test's webhook config at
|
|
1282
|
+
`hook.ingest_url`."""
|
|
1283
|
+
body: Dict[str, Any] = {}
|
|
1284
|
+
if label is not None:
|
|
1285
|
+
body["label"] = label
|
|
1286
|
+
if ttl_seconds is not None:
|
|
1287
|
+
body["ttl_seconds"] = ttl_seconds
|
|
1288
|
+
data = self._request("POST", "/v1/hooks", body=body)
|
|
1289
|
+
return Hook._from_json(data, self)
|
|
1290
|
+
|
|
1291
|
+
def list_hooks(self) -> List[Hook]:
|
|
1292
|
+
data = self._request("GET", "/v1/hooks")
|
|
1293
|
+
return [Hook._from_json(h, self) for h in data["hooks"]]
|
|
1294
|
+
|
|
1295
|
+
def delete_hook(self, hook_id: str) -> None:
|
|
1296
|
+
"""Delete the hook and its captured requests; the URL 404s after."""
|
|
1297
|
+
self._request("DELETE", f"/v1/hooks/{hook_id}")
|
|
1298
|
+
|
|
1299
|
+
def clear_hook(self, hook_id: str) -> None:
|
|
1300
|
+
"""Drop captured requests but keep the hook — rerun hygiene."""
|
|
1301
|
+
self._request("POST", f"/v1/hooks/{hook_id}/clear")
|
|
1302
|
+
|
|
1303
|
+
def list_hook_requests(
|
|
1304
|
+
self,
|
|
1305
|
+
hook_id: str,
|
|
1306
|
+
since: Optional[str] = None,
|
|
1307
|
+
match: Optional[str] = None,
|
|
1308
|
+
wait: Optional[int] = None,
|
|
1309
|
+
) -> List[HookRequestSummary]:
|
|
1310
|
+
"""List a hook's captured requests, oldest first.
|
|
1311
|
+
|
|
1312
|
+
`match` filters with `method:` (exact verb, case-insensitive),
|
|
1313
|
+
`path:` or `body:` prefixes (substring; a bare term matches the
|
|
1314
|
+
body). Body matching scans the first 32 KiB; detail bodies remain
|
|
1315
|
+
complete. `wait` long-polls up to that many seconds (server ceiling
|
|
1316
|
+
60) until a matching request arrives."""
|
|
1317
|
+
params: Dict[str, str] = {}
|
|
1318
|
+
if since is not None:
|
|
1319
|
+
params["since"] = since
|
|
1320
|
+
if match is not None:
|
|
1321
|
+
params["match"] = match
|
|
1322
|
+
if wait is not None:
|
|
1323
|
+
params["wait"] = str(wait)
|
|
1324
|
+
data = self._request(
|
|
1325
|
+
"GET",
|
|
1326
|
+
f"/v1/hooks/{hook_id}/requests",
|
|
1327
|
+
params=params,
|
|
1328
|
+
read_timeout=(wait or 0) + 15,
|
|
1329
|
+
)
|
|
1330
|
+
return [HookRequestSummary._from_json(r) for r in data["requests"]]
|
|
1331
|
+
|
|
1332
|
+
def get_hook_request(self, request_id: str) -> HookRequest:
|
|
1333
|
+
return HookRequest._from_json(
|
|
1334
|
+
self._request("GET", f"/v1/hook-requests/{request_id}")
|
|
1335
|
+
)
|
|
1336
|
+
|
|
1337
|
+
def wait_for_hook_request(
|
|
1338
|
+
self,
|
|
1339
|
+
hook_id: str,
|
|
1340
|
+
match: Optional[str] = None,
|
|
1341
|
+
since: Optional[str] = None,
|
|
1342
|
+
timeout: float = 45.0,
|
|
1343
|
+
) -> HookRequest:
|
|
1344
|
+
"""Long-poll until a matching request is captured; returns the
|
|
1345
|
+
full detail. Raises `MailFixtureTimeout` after `timeout` seconds."""
|
|
1346
|
+
deadline = time.monotonic() + timeout
|
|
1347
|
+
cursor = since
|
|
1348
|
+
while True:
|
|
1349
|
+
remaining = deadline - time.monotonic()
|
|
1350
|
+
summaries = self._poll_hook_requests(hook_id, cursor, match, remaining)
|
|
1351
|
+
if summaries:
|
|
1352
|
+
return self.get_hook_request(summaries[0].id)
|
|
1353
|
+
if deadline - time.monotonic() <= 0:
|
|
1354
|
+
raise MailFixtureTimeout(
|
|
1355
|
+
f"no captured request matching {match!r} within {timeout}s"
|
|
1356
|
+
)
|
|
1357
|
+
|
|
1358
|
+
def _poll_hook_requests(
|
|
1359
|
+
self,
|
|
1360
|
+
hook_id: str,
|
|
1361
|
+
since: Optional[str],
|
|
1362
|
+
match: Optional[str],
|
|
1363
|
+
remaining: float,
|
|
1364
|
+
) -> List[HookRequestSummary]:
|
|
1365
|
+
"""One long-poll chunk over captured requests — see `_poll_chunk`."""
|
|
1366
|
+
return self._poll_chunk(
|
|
1367
|
+
lambda **kw: self.list_hook_requests(hook_id, **kw), since, match, remaining
|
|
1368
|
+
)
|
|
1118
1369
|
|
|
1119
1370
|
# -- domains -----------------------------------------------------------
|
|
1120
1371
|
|
|
@@ -45,7 +45,11 @@ MESSAGE_DETAIL = {
|
|
|
45
45
|
"inbox_id": INBOX_ID,
|
|
46
46
|
"text_body": "Your code is 482913",
|
|
47
47
|
"html_body": None,
|
|
48
|
-
"headers":
|
|
48
|
+
"headers": [
|
|
49
|
+
{"name": "Received", "value": "from mx1.acme.test"},
|
|
50
|
+
{"name": "Received", "value": "from mx2.acme.test"},
|
|
51
|
+
{"name": "Message-ID", "value": "<1@acme.test>"},
|
|
52
|
+
],
|
|
49
53
|
"extracted": {
|
|
50
54
|
"otp": {"best": "482913", "candidates": [{"value": "482913", "score": 9.5}]},
|
|
51
55
|
"links": [
|
|
@@ -107,6 +111,56 @@ SMS_DETAIL = {
|
|
|
107
111
|
"expires_at": "2026-07-08T12:00:09Z",
|
|
108
112
|
}
|
|
109
113
|
|
|
114
|
+
HOOK_ID = "7c9e1f3a-5b6d-4e2f-8a0b-1c3d5e7f9a0c"
|
|
115
|
+
HOOK_REQ_ID = "2d4f6a8b-0c1d-4e3f-9a5b-6c8d0e2f4a6d"
|
|
116
|
+
HOOK = {
|
|
117
|
+
"id": HOOK_ID,
|
|
118
|
+
"token": "tokentokentokentokentokentoken12",
|
|
119
|
+
"ingest_url": "https://hook.mxsink.sh/tokentokentokentokentokentoken12",
|
|
120
|
+
"label": "checkout",
|
|
121
|
+
"ttl_expires_at": None,
|
|
122
|
+
"created_at": T0,
|
|
123
|
+
}
|
|
124
|
+
HOOK_REQ_SUMMARY = {
|
|
125
|
+
"id": HOOK_REQ_ID,
|
|
126
|
+
"hook_id": HOOK_ID,
|
|
127
|
+
"received_at": SMS_T2,
|
|
128
|
+
"method": "POST",
|
|
129
|
+
"path": "/?src=stripe",
|
|
130
|
+
"content_type": "application/json",
|
|
131
|
+
"body_size": 8, # b"bin\x00data" is 8 bytes — the stub stays wire-honest
|
|
132
|
+
}
|
|
133
|
+
# A plain-text capture: body_is_utf8 True and NO body_base64 key on the
|
|
134
|
+
# wire (the server omits it for valid UTF-8 bodies).
|
|
135
|
+
HOOK_REQ_TEXT_ID = "3e5a7c9d-1f2a-4b6c-8d0e-2f4a6c8e0b1d"
|
|
136
|
+
HOOK_REQ_TEXT_DETAIL = {
|
|
137
|
+
"id": HOOK_REQ_TEXT_ID,
|
|
138
|
+
"hook_id": HOOK_ID,
|
|
139
|
+
"received_at": SMS_T2,
|
|
140
|
+
"method": "POST",
|
|
141
|
+
"path": "/",
|
|
142
|
+
"content_type": "application/json",
|
|
143
|
+
"body_size": 16,
|
|
144
|
+
"headers": [],
|
|
145
|
+
"body": '{"event":"paid"}',
|
|
146
|
+
"body_is_utf8": True,
|
|
147
|
+
"remote_ip": None,
|
|
148
|
+
"expires_at": "2026-07-08T12:00:09Z",
|
|
149
|
+
}
|
|
150
|
+
HOOK_REQ_DETAIL = {
|
|
151
|
+
**HOOK_REQ_SUMMARY,
|
|
152
|
+
"headers": [
|
|
153
|
+
{"name": "content-type", "value": "application/json"},
|
|
154
|
+
{"name": "x-sig", "value": "one"},
|
|
155
|
+
{"name": "x-sig", "value": "two"},
|
|
156
|
+
],
|
|
157
|
+
"body": "bin�data",
|
|
158
|
+
"body_base64": "YmluAGRhdGE=",
|
|
159
|
+
"body_is_utf8": False,
|
|
160
|
+
"remote_ip": "203.0.113.9",
|
|
161
|
+
"expires_at": "2026-07-08T12:00:09Z",
|
|
162
|
+
}
|
|
163
|
+
|
|
110
164
|
|
|
111
165
|
class Stub(BaseHTTPRequestHandler):
|
|
112
166
|
# Per-server-instance state, reset by each test via make_server().
|
|
@@ -158,6 +212,12 @@ class Stub(BaseHTTPRequestHandler):
|
|
|
158
212
|
self._problem(403, "forbidden", "phone numbers require a paid plan")
|
|
159
213
|
else:
|
|
160
214
|
self._json(201, PHONE_NUMBER)
|
|
215
|
+
elif path == "/v1/hooks":
|
|
216
|
+
self.state["hook_create_body"] = body
|
|
217
|
+
self._json(201, HOOK)
|
|
218
|
+
elif path == f"/v1/hooks/{HOOK_ID}/clear":
|
|
219
|
+
self.send_response(204)
|
|
220
|
+
self.end_headers()
|
|
161
221
|
elif path == f"/v1/webhooks/{WH_ID}/test":
|
|
162
222
|
self._json(202, {"delivery_id": WH_DELIVERY_ID})
|
|
163
223
|
elif path == f"/v1/webhooks/{WH_ID}/enable":
|
|
@@ -247,6 +307,21 @@ class Stub(BaseHTTPRequestHandler):
|
|
|
247
307
|
"last_status_code": 200, "last_error": None,
|
|
248
308
|
"created_at": T0, "delivered_at": T1,
|
|
249
309
|
}]})
|
|
310
|
+
elif parsed.path == "/v1/hooks":
|
|
311
|
+
self._json(200, {"hooks": [HOOK]})
|
|
312
|
+
elif parsed.path == f"/v1/hooks/{HOOK_ID}/requests":
|
|
313
|
+
polls = self.state.setdefault("hook_polls", 0)
|
|
314
|
+
self.state["hook_polls"] += 1
|
|
315
|
+
if self.state.get("hook_rate_limit_first") and polls == 0:
|
|
316
|
+
self._problem(429, "too many requests", "slow down", {"Retry-After": "1"})
|
|
317
|
+
elif polls < self.state.get("deliver_hook_on_poll", 0):
|
|
318
|
+
self._json(200, {"requests": []})
|
|
319
|
+
else:
|
|
320
|
+
self._json(200, {"requests": [HOOK_REQ_SUMMARY]})
|
|
321
|
+
elif parsed.path == f"/v1/hook-requests/{HOOK_REQ_ID}":
|
|
322
|
+
self._json(200, HOOK_REQ_DETAIL)
|
|
323
|
+
elif parsed.path == f"/v1/hook-requests/{HOOK_REQ_TEXT_ID}":
|
|
324
|
+
self._json(200, HOOK_REQ_TEXT_DETAIL)
|
|
250
325
|
elif parsed.path == f"/v1/messages/{MSG_ID}/attachments/0":
|
|
251
326
|
self.send_response(200)
|
|
252
327
|
self.send_header("Content-Type", "application/octet-stream")
|
|
@@ -261,6 +336,23 @@ class Stub(BaseHTTPRequestHandler):
|
|
|
261
336
|
self.end_headers()
|
|
262
337
|
|
|
263
338
|
|
|
339
|
+
class VersionLockstepTest(unittest.TestCase):
|
|
340
|
+
def test_dunder_version_matches_pyproject(self):
|
|
341
|
+
# pyproject.toml once shipped stuck at 0.2.0 while __version__ said
|
|
342
|
+
# 0.3.0 (and the JS SDK repeated the class of bug at 0.4.1/0.5.0);
|
|
343
|
+
# this pins the two so `just sdk-test` fails when a bump misses one.
|
|
344
|
+
# Regex over tomllib: the SDK supports 3.9+, tomllib is 3.11+.
|
|
345
|
+
import pathlib
|
|
346
|
+
import re
|
|
347
|
+
|
|
348
|
+
import mailfixture
|
|
349
|
+
|
|
350
|
+
pyproject = pathlib.Path(__file__).resolve().parent.parent / "pyproject.toml"
|
|
351
|
+
match = re.search(r'^version = "([^"]+)"$', pyproject.read_text(), re.M)
|
|
352
|
+
assert match, "no version line found in pyproject.toml"
|
|
353
|
+
self.assertEqual(mailfixture.__version__, match.group(1))
|
|
354
|
+
|
|
355
|
+
|
|
264
356
|
class ClientTest(unittest.TestCase):
|
|
265
357
|
def setUp(self):
|
|
266
358
|
state = {"requests": [], "polls": 0, "sms_polls": 0}
|
|
@@ -299,6 +391,12 @@ class ClientTest(unittest.TestCase):
|
|
|
299
391
|
message = self.client.wait_for_message(INBOX_ID, match="subject:Acme", timeout=5)
|
|
300
392
|
self.assertEqual(message.otp, "482913")
|
|
301
393
|
self.assertEqual(message.text_body, "Your code is 482913")
|
|
394
|
+
# headers: the wire's ordered [{name, value}] list, dupes kept
|
|
395
|
+
self.assertIsInstance(message.headers, list)
|
|
396
|
+
self.assertEqual(message.headers[2], {"name": "Message-ID", "value": "<1@acme.test>"})
|
|
397
|
+
self.assertEqual(
|
|
398
|
+
len([h for h in message.headers if h["name"].lower() == "received"]), 2
|
|
399
|
+
)
|
|
302
400
|
self.assertEqual(message.attachments[0].filename, "report.pdf")
|
|
303
401
|
self.assertTrue(message.tls)
|
|
304
402
|
self.assertEqual(message.auth.spf.result, "pass")
|
|
@@ -466,6 +564,78 @@ class ClientTest(unittest.TestCase):
|
|
|
466
564
|
self.client.delete_webhook(created.id)
|
|
467
565
|
self.assertEqual(self.state["requests"][-1][0], "DELETE")
|
|
468
566
|
|
|
567
|
+
def test_create_hook_sends_snake_case_body(self):
|
|
568
|
+
hook = self.client.create_hook(label="checkout", ttl_seconds=900)
|
|
569
|
+
self.assertEqual(hook.id, HOOK_ID)
|
|
570
|
+
self.assertEqual(hook.ingest_url, HOOK["ingest_url"])
|
|
571
|
+
self.assertEqual(
|
|
572
|
+
self.state["hook_create_body"], {"label": "checkout", "ttl_seconds": 900}
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
def test_wait_for_hook_request_survives_empty_polls(self):
|
|
576
|
+
self.state["deliver_hook_on_poll"] = 2
|
|
577
|
+
hook = self.client.create_hook()
|
|
578
|
+
req = hook.wait_for_request(match="method:POST", timeout=10)
|
|
579
|
+
self.assertEqual(req.id, HOOK_REQ_ID)
|
|
580
|
+
self.assertEqual(req.method, "POST")
|
|
581
|
+
self.assertEqual(req.path, "/?src=stripe")
|
|
582
|
+
# binary body: lossy text + exact bytes via body_base64
|
|
583
|
+
self.assertFalse(req.body_is_utf8)
|
|
584
|
+
self.assertEqual(req.body_bytes, b"bin\x00data")
|
|
585
|
+
# duplicates preserved in header order
|
|
586
|
+
self.assertEqual(
|
|
587
|
+
[h.value for h in req.headers if h.name == "x-sig"], ["one", "two"]
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
def test_hook_list_requests_clear_delete(self):
|
|
591
|
+
hooks = self.client.list_hooks()
|
|
592
|
+
self.assertEqual(hooks[0].id, HOOK_ID)
|
|
593
|
+
requests = hooks[0].requests()
|
|
594
|
+
self.assertEqual(requests[0].hook_id, HOOK_ID)
|
|
595
|
+
self.assertEqual(requests[0].body_size, 8)
|
|
596
|
+
hooks[0].clear()
|
|
597
|
+
hooks[0].delete()
|
|
598
|
+
calls = [(m, urlparse(p).path) for m, p, _ in self.state["requests"]]
|
|
599
|
+
self.assertIn(("POST", f"/v1/hooks/{HOOK_ID}/clear"), calls)
|
|
600
|
+
self.assertIn(("DELETE", f"/v1/hooks/{HOOK_ID}"), calls)
|
|
601
|
+
|
|
602
|
+
def test_hook_wait_absorbs_429_mid_wait(self):
|
|
603
|
+
# The hook poll path is its own lambda into _poll_chunk; the
|
|
604
|
+
# email-path 429 test proves nothing about it.
|
|
605
|
+
self.state["hook_rate_limit_first"] = True
|
|
606
|
+
req = self.client.wait_for_hook_request(HOOK_ID, timeout=10)
|
|
607
|
+
self.assertEqual(req.id, HOOK_REQ_ID)
|
|
608
|
+
self.assertGreaterEqual(self.state["hook_polls"], 2)
|
|
609
|
+
|
|
610
|
+
def test_hook_wait_sends_wire_params(self):
|
|
611
|
+
self.client.wait_for_hook_request(
|
|
612
|
+
HOOK_ID, match="method:POST", since=SMS_T1, timeout=10
|
|
613
|
+
)
|
|
614
|
+
polls = [
|
|
615
|
+
parse_qs(urlparse(p).query)
|
|
616
|
+
for m, p, _ in self.state["requests"]
|
|
617
|
+
if urlparse(p).path == f"/v1/hooks/{HOOK_ID}/requests"
|
|
618
|
+
]
|
|
619
|
+
self.assertGreaterEqual(len(polls), 1)
|
|
620
|
+
for params in polls:
|
|
621
|
+
self.assertEqual(params["match"], ["method:POST"])
|
|
622
|
+
self.assertEqual(params["since"], [SMS_T1])
|
|
623
|
+
self.assertTrue(0 < int(params["wait"][0]) <= 60)
|
|
624
|
+
|
|
625
|
+
def test_hook_wait_times_out(self):
|
|
626
|
+
self.state["deliver_hook_on_poll"] = 10**9
|
|
627
|
+
with self.assertRaises(MailFixtureTimeout):
|
|
628
|
+
self.client.wait_for_hook_request(HOOK_ID, timeout=0.3)
|
|
629
|
+
|
|
630
|
+
def test_hook_body_bytes_utf8_branch(self):
|
|
631
|
+
# The binary branch is pinned by the wait test; this is the encode
|
|
632
|
+
# path: a valid-UTF-8 body has no body_base64 and body_bytes must
|
|
633
|
+
# be the exact UTF-8 encoding.
|
|
634
|
+
req = self.client.get_hook_request(HOOK_REQ_TEXT_ID)
|
|
635
|
+
self.assertTrue(req.body_is_utf8)
|
|
636
|
+
self.assertIsNone(req.body_base64)
|
|
637
|
+
self.assertEqual(req.body_bytes, b'{"event":"paid"}')
|
|
638
|
+
|
|
469
639
|
def test_verify_webhook_signature(self):
|
|
470
640
|
import hashlib
|
|
471
641
|
import hmac as hmac_mod
|
|
File without changes
|
|
File without changes
|