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.
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 MailFixture, Inc.
3
+ Copyright (c) 2026 Foundry86 LLC
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -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.5.0"
8
- description = "Email fixtures for your test suite — programmatic inboxes, long-polling, first-class OTP and link extraction."
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.5.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
- headers: Dict[str, Any]
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 _poll(
956
- self,
957
- inbox_id: str,
958
- since: Optional[str],
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 self.list_messages(inbox_id, since=since, match=match, wait=wait)
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, rate-limit aware."""
1110
- wait = max(0, min(math.ceil(remaining), MAX_SERVER_WAIT))
1111
- try:
1112
- return self.list_sms(phone_number_id, since=since, match=match, wait=wait)
1113
- except MailFixtureError as err:
1114
- if err.status == 429 and err.retry_after and err.retry_after < remaining:
1115
- time.sleep(err.retry_after)
1116
- return []
1117
- raise
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": {"message-id": "<1@acme.test>"},
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