mailfixture 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MailFixture, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: mailfixture
3
+ Version: 0.1.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
+ # mailfixture
20
+
21
+ Email fixtures for your test suite. Create a programmatic inbox in one call,
22
+ point your app's signup / OTP / password-reset flow at it, then wait for the
23
+ email — parsed, with the OTP and links already extracted.
24
+
25
+ Receive-only, built for CI: long-polling instead of retry loops, zero
26
+ dependencies outside the Python standard library, Python 3.9+.
27
+
28
+ ```bash
29
+ pip install mailfixture
30
+ ```
31
+
32
+ ## Quickstart
33
+
34
+ Create a key in [Dashboard → API keys](https://mailfixture.com/app/keys) and
35
+ export it as `MAILFIXTURE_API_KEY`.
36
+
37
+ ```python
38
+ from mailfixture import MailFixture
39
+
40
+ mfx = MailFixture() # reads MAILFIXTURE_API_KEY
41
+
42
+ def test_signup_sends_otp(page):
43
+ inbox = mfx.create_inbox(ttl_seconds=900)
44
+
45
+ page.goto("/signup")
46
+ page.fill("#email", inbox.email_address)
47
+ page.click("text=Send code")
48
+
49
+ otp = inbox.wait_for_otp(timeout=30) # long-polls; no sleep()
50
+ page.fill("#otp", otp)
51
+ ```
52
+
53
+ Waiting for a magic link instead:
54
+
55
+ ```python
56
+ link = inbox.wait_for_link(kind="verify", timeout=30)
57
+ page.goto(link.url)
58
+ ```
59
+
60
+ Everything on the wait helpers is server-side long-polling: the request is
61
+ held open until a matching message arrives (or the timeout passes, raising
62
+ `mailfixture.MailFixtureTimeout`).
63
+
64
+ ## API surface
65
+
66
+ | Method | Purpose |
67
+ | --- | --- |
68
+ | `create_inbox(local_part=, domain=, ttl_seconds=)` | new inbox; returns an `Inbox` handle |
69
+ | `inbox.wait_for_message(match=, timeout=)` | first matching message, full body + extraction |
70
+ | `inbox.wait_for_otp(match=, timeout=)` | best extracted OTP as a string |
71
+ | `inbox.wait_for_link(kind=, match=, timeout=)` | first link, optionally `verify\|reset\|unsubscribe` |
72
+ | `inbox.messages(since=, match=, wait=)` / `clear()` / `delete()` | raw listing & lifecycle |
73
+ | `get_message(id)`, `get_otp(id)`, `get_links(id)` | direct message access |
74
+ | `download_attachment(message_id, index)` | decoded attachment bytes |
75
+ | `create_domain(fqdn)`, `verify_domain(id)`, `list_domains()`, `delete_domain(id)` | custom domains |
76
+ | `create_key(label=)`, `list_keys()`, `revoke_key(id)` | API keys |
77
+
78
+ `match` filters with `subject:`, `from:`, or `to:` prefixes; a bare term
79
+ matches the subject.
80
+
81
+ Errors are raised as `mailfixture.MailFixtureError` carrying the API's
82
+ RFC 7807 `status` / `title` / `detail` fields.
83
+
84
+ ## Development
85
+
86
+ ```bash
87
+ PYTHONPATH=src python3 -m unittest discover -s tests
88
+ ```
89
+
90
+ Docs: https://mailfixture.com/docs
@@ -0,0 +1,72 @@
1
+ # mailfixture
2
+
3
+ Email fixtures for your test suite. Create a programmatic inbox in one call,
4
+ point your app's signup / OTP / password-reset flow at it, then wait for the
5
+ email — parsed, with the OTP and links already extracted.
6
+
7
+ Receive-only, built for CI: long-polling instead of retry loops, zero
8
+ dependencies outside the Python standard library, Python 3.9+.
9
+
10
+ ```bash
11
+ pip install mailfixture
12
+ ```
13
+
14
+ ## Quickstart
15
+
16
+ Create a key in [Dashboard → API keys](https://mailfixture.com/app/keys) and
17
+ export it as `MAILFIXTURE_API_KEY`.
18
+
19
+ ```python
20
+ from mailfixture import MailFixture
21
+
22
+ mfx = MailFixture() # reads MAILFIXTURE_API_KEY
23
+
24
+ def test_signup_sends_otp(page):
25
+ inbox = mfx.create_inbox(ttl_seconds=900)
26
+
27
+ page.goto("/signup")
28
+ page.fill("#email", inbox.email_address)
29
+ page.click("text=Send code")
30
+
31
+ otp = inbox.wait_for_otp(timeout=30) # long-polls; no sleep()
32
+ page.fill("#otp", otp)
33
+ ```
34
+
35
+ Waiting for a magic link instead:
36
+
37
+ ```python
38
+ link = inbox.wait_for_link(kind="verify", timeout=30)
39
+ page.goto(link.url)
40
+ ```
41
+
42
+ Everything on the wait helpers is server-side long-polling: the request is
43
+ held open until a matching message arrives (or the timeout passes, raising
44
+ `mailfixture.MailFixtureTimeout`).
45
+
46
+ ## API surface
47
+
48
+ | Method | Purpose |
49
+ | --- | --- |
50
+ | `create_inbox(local_part=, domain=, ttl_seconds=)` | new inbox; returns an `Inbox` handle |
51
+ | `inbox.wait_for_message(match=, timeout=)` | first matching message, full body + extraction |
52
+ | `inbox.wait_for_otp(match=, timeout=)` | best extracted OTP as a string |
53
+ | `inbox.wait_for_link(kind=, match=, timeout=)` | first link, optionally `verify\|reset\|unsubscribe` |
54
+ | `inbox.messages(since=, match=, wait=)` / `clear()` / `delete()` | raw listing & lifecycle |
55
+ | `get_message(id)`, `get_otp(id)`, `get_links(id)` | direct message access |
56
+ | `download_attachment(message_id, index)` | decoded attachment bytes |
57
+ | `create_domain(fqdn)`, `verify_domain(id)`, `list_domains()`, `delete_domain(id)` | custom domains |
58
+ | `create_key(label=)`, `list_keys()`, `revoke_key(id)` | API keys |
59
+
60
+ `match` filters with `subject:`, `from:`, or `to:` prefixes; a bare term
61
+ matches the subject.
62
+
63
+ Errors are raised as `mailfixture.MailFixtureError` carrying the API's
64
+ RFC 7807 `status` / `title` / `detail` fields.
65
+
66
+ ## Development
67
+
68
+ ```bash
69
+ PYTHONPATH=src python3 -m unittest discover -s tests
70
+ ```
71
+
72
+ Docs: https://mailfixture.com/docs
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mailfixture"
7
+ version = "0.1.0"
8
+ description = "Email fixtures for your test suite — programmatic inboxes, long-polling, first-class OTP and link extraction."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ authors = [{ name = "MailFixture", email = "support@mailfixture.com" }]
14
+ keywords = ["email", "testing", "otp", "fixtures", "e2e", "playwright", "pytest"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Software Development :: Testing",
20
+ "Typing :: Typed",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://mailfixture.com"
25
+ Documentation = "https://mailfixture.com/docs"
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/mailfixture"]
@@ -0,0 +1,801 @@
1
+ """MailFixture — email fixtures for your test suite.
2
+
3
+ Create a programmatic inbox, point your app's signup/OTP/reset flow at it,
4
+ then wait for the email and assert on the extracted OTP or link:
5
+
6
+ from mailfixture import MailFixture
7
+
8
+ mfx = MailFixture() # reads MAILFIXTURE_API_KEY
9
+ inbox = mfx.create_inbox(ttl_seconds=900)
10
+ # ... drive your app to send mail to inbox.email_address ...
11
+ otp = inbox.wait_for_otp(timeout=30)
12
+
13
+ The client is synchronous and has zero dependencies outside the standard
14
+ library. Long-polling is done server-side (`wait=`), so there is no sleep
15
+ to tune and no flaky retry loop.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import hmac
22
+ import json
23
+ import math
24
+ import os
25
+ import time
26
+ import urllib.error
27
+ import urllib.parse
28
+ import urllib.request
29
+ from dataclasses import dataclass, field
30
+ from datetime import datetime
31
+ from typing import Any, Dict, List, Optional
32
+
33
+ __all__ = [
34
+ "MailFixture",
35
+ "Inbox",
36
+ "InboxSummary",
37
+ "Message",
38
+ "MessageSummary",
39
+ "Otp",
40
+ "OtpCandidate",
41
+ "Link",
42
+ "Attachment",
43
+ "Domain",
44
+ "DomainVerification",
45
+ "ApiKey",
46
+ "CreatedApiKey",
47
+ "Webhook",
48
+ "CreatedWebhook",
49
+ "WebhookDelivery",
50
+ "verify_webhook_signature",
51
+ "MailFixtureError",
52
+ "MailFixtureTimeout",
53
+ ]
54
+
55
+ __version__ = "0.1.0"
56
+
57
+ DEFAULT_BASE_URL = "https://api.mailfixture.com"
58
+ #: Server-side ceiling for one long-poll request; the client chains
59
+ #: requests until its own deadline, so timeouts may exceed this.
60
+ MAX_SERVER_WAIT = 60
61
+
62
+
63
+ class MailFixtureError(Exception):
64
+ """An RFC 7807 problem+json error response from the API."""
65
+
66
+ def __init__(
67
+ self,
68
+ status: Optional[int],
69
+ title: str,
70
+ detail: Optional[str] = None,
71
+ retry_after: Optional[int] = None,
72
+ ) -> None:
73
+ self.status = status
74
+ self.title = title
75
+ self.detail = detail
76
+ self.retry_after = retry_after
77
+ message = f"{title} (HTTP {status})" if status else title
78
+ if detail:
79
+ message = f"{message}: {detail}"
80
+ super().__init__(message)
81
+
82
+
83
+ class MailFixtureTimeout(MailFixtureError):
84
+ """No matching message arrived before the client-side deadline."""
85
+
86
+ def __init__(self, detail: str) -> None:
87
+ super().__init__(None, "timed out", detail)
88
+
89
+
90
+ def _parse_dt(value: str) -> datetime:
91
+ # Python < 3.11 fromisoformat rejects a trailing Z.
92
+ if value.endswith("Z"):
93
+ value = value[:-1] + "+00:00"
94
+ return datetime.fromisoformat(value)
95
+
96
+
97
+ @dataclass
98
+ class OtpCandidate:
99
+ value: str
100
+ score: float
101
+
102
+
103
+ @dataclass
104
+ class Otp:
105
+ """Ranked OTP candidates extracted from a message; `best` is the winner."""
106
+
107
+ best: Optional[str]
108
+ candidates: List[OtpCandidate]
109
+
110
+ @classmethod
111
+ def _from_json(cls, data: Dict[str, Any]) -> "Otp":
112
+ return cls(
113
+ best=data.get("best"),
114
+ candidates=[
115
+ OtpCandidate(value=c["value"], score=c["score"])
116
+ for c in data.get("candidates", [])
117
+ ],
118
+ )
119
+
120
+
121
+ @dataclass
122
+ class Link:
123
+ """A URL extracted from a message. `kind` is the server's guess:
124
+ verify | reset | unsubscribe | other (wire name: `class`)."""
125
+
126
+ url: str
127
+ text: Optional[str]
128
+ kind: str
129
+
130
+ def __str__(self) -> str:
131
+ return self.url
132
+
133
+ @classmethod
134
+ def _from_json(cls, data: Dict[str, Any]) -> "Link":
135
+ return cls(url=data["url"], text=data.get("text"), kind=data.get("class", "other"))
136
+
137
+
138
+ @dataclass
139
+ class Attachment:
140
+ filename: str
141
+ content_type: str
142
+ size: int
143
+ inline: bool
144
+ index: int
145
+
146
+
147
+ @dataclass
148
+ class MessageSummary:
149
+ id: str
150
+ received_at: datetime
151
+ from_addr: str
152
+ to_addrs: List[str]
153
+ subject: Optional[str]
154
+ #: Wire-format timestamp, used verbatim as the next `since` cursor.
155
+ received_at_raw: str = field(repr=False, default="")
156
+
157
+ @classmethod
158
+ def _from_json(cls, data: Dict[str, Any]) -> "MessageSummary":
159
+ return cls(
160
+ id=data["id"],
161
+ received_at=_parse_dt(data["received_at"]),
162
+ from_addr=data["from_addr"],
163
+ to_addrs=list(data["to_addrs"]),
164
+ subject=data.get("subject"),
165
+ received_at_raw=data["received_at"],
166
+ )
167
+
168
+
169
+ @dataclass
170
+ class Message:
171
+ id: str
172
+ inbox_id: str
173
+ received_at: datetime
174
+ from_addr: str
175
+ to_addrs: List[str]
176
+ subject: Optional[str]
177
+ text_body: Optional[str]
178
+ html_body: Optional[str]
179
+ headers: Dict[str, Any]
180
+ extracted: Dict[str, Any]
181
+ attachments: List[Attachment]
182
+ raw_size: int
183
+ expires_at: datetime
184
+
185
+ @property
186
+ def otp(self) -> Optional[str]:
187
+ """The best extracted OTP, if any."""
188
+ return (self.extracted.get("otp") or {}).get("best")
189
+
190
+ @property
191
+ def links(self) -> List[Link]:
192
+ return [Link._from_json(l) for l in self.extracted.get("links", [])]
193
+
194
+ @classmethod
195
+ def _from_json(cls, data: Dict[str, Any]) -> "Message":
196
+ return cls(
197
+ id=data["id"],
198
+ inbox_id=data["inbox_id"],
199
+ received_at=_parse_dt(data["received_at"]),
200
+ from_addr=data["from_addr"],
201
+ to_addrs=list(data["to_addrs"]),
202
+ subject=data.get("subject"),
203
+ text_body=data.get("text_body"),
204
+ html_body=data.get("html_body"),
205
+ headers=data.get("headers") or {},
206
+ extracted=data.get("extracted") or {},
207
+ attachments=[
208
+ Attachment(
209
+ filename=a.get("filename", ""),
210
+ content_type=a.get("content_type", ""),
211
+ size=a.get("size", 0),
212
+ inline=a.get("inline", False),
213
+ index=i,
214
+ )
215
+ for i, a in enumerate(data.get("attachments") or [])
216
+ ],
217
+ raw_size=data.get("raw_size", 0),
218
+ expires_at=_parse_dt(data["expires_at"]),
219
+ )
220
+
221
+
222
+ @dataclass
223
+ class InboxSummary:
224
+ id: str
225
+ local_part: str
226
+ fqdn: str
227
+ ttl_expires_at: Optional[datetime]
228
+ created_at: datetime
229
+ message_count: int
230
+
231
+ @property
232
+ def email_address(self) -> str:
233
+ return f"{self.local_part}@{self.fqdn}"
234
+
235
+ @classmethod
236
+ def _from_json(cls, data: Dict[str, Any]) -> "InboxSummary":
237
+ ttl = data.get("ttl_expires_at")
238
+ return cls(
239
+ id=data["id"],
240
+ local_part=data["local_part"],
241
+ fqdn=data["fqdn"],
242
+ ttl_expires_at=_parse_dt(ttl) if ttl else None,
243
+ created_at=_parse_dt(data["created_at"]),
244
+ message_count=data.get("message_count", 0),
245
+ )
246
+
247
+
248
+ @dataclass
249
+ class Domain:
250
+ id: str
251
+ fqdn: str
252
+ kind: str
253
+ verified_at: Optional[datetime]
254
+ mx_ok_at: Optional[datetime]
255
+ created_at: datetime
256
+ #: DNS records to publish while unverified: {"txt": {...}, "mx": {...}}.
257
+ setup: Optional[Dict[str, Any]] = None
258
+
259
+ @classmethod
260
+ def _from_json(cls, data: Dict[str, Any]) -> "Domain":
261
+ verified = data.get("verified_at")
262
+ mx_ok = data.get("mx_ok_at")
263
+ return cls(
264
+ id=data["id"],
265
+ fqdn=data["fqdn"],
266
+ kind=data["kind"],
267
+ verified_at=_parse_dt(verified) if verified else None,
268
+ mx_ok_at=_parse_dt(mx_ok) if mx_ok else None,
269
+ created_at=_parse_dt(data["created_at"]),
270
+ setup=data.get("setup"),
271
+ )
272
+
273
+
274
+ @dataclass
275
+ class DomainVerification:
276
+ id: str
277
+ fqdn: str
278
+ txt_ok: bool
279
+ mx_ok: bool
280
+ verified_at: Optional[datetime]
281
+ mx_ok_at: Optional[datetime]
282
+ setup: Dict[str, Any]
283
+
284
+ @property
285
+ def verified(self) -> bool:
286
+ return self.verified_at is not None and self.mx_ok_at is not None
287
+
288
+
289
+ @dataclass
290
+ class ApiKey:
291
+ id: str
292
+ prefix: str
293
+ label: Optional[str]
294
+ created_at: datetime
295
+ revoked_at: Optional[datetime]
296
+
297
+
298
+ @dataclass
299
+ class CreatedApiKey:
300
+ id: str
301
+ prefix: str
302
+ #: The raw key. Shown exactly once — store it now.
303
+ key: str
304
+
305
+
306
+ @dataclass
307
+ class Webhook:
308
+ """A registered webhook endpoint (paid plans)."""
309
+
310
+ id: str
311
+ url: str
312
+ description: Optional[str]
313
+ #: Masked signing secret, e.g. "whsec_…abcd".
314
+ secret_hint: str
315
+ consecutive_failures: int
316
+ disabled_at: Optional[datetime]
317
+ disabled_reason: Optional[str]
318
+ created_at: datetime
319
+
320
+ @classmethod
321
+ def _from_json(cls, data: Dict[str, Any]) -> "Webhook":
322
+ disabled = data.get("disabled_at")
323
+ return cls(
324
+ id=data["id"],
325
+ url=data["url"],
326
+ description=data.get("description"),
327
+ secret_hint=data["secret_hint"],
328
+ consecutive_failures=data.get("consecutive_failures", 0),
329
+ disabled_at=_parse_dt(disabled) if disabled else None,
330
+ disabled_reason=data.get("disabled_reason"),
331
+ created_at=_parse_dt(data["created_at"]),
332
+ )
333
+
334
+
335
+ @dataclass
336
+ class CreatedWebhook:
337
+ id: str
338
+ url: str
339
+ description: Optional[str]
340
+ #: The signing secret. Shown exactly once — store it now.
341
+ secret: str
342
+ created_at: datetime
343
+
344
+
345
+ @dataclass
346
+ class WebhookDelivery:
347
+ """One event delivery attempt trail for an endpoint."""
348
+
349
+ id: str
350
+ event_type: str
351
+ #: pending | succeeded | failed | skipped
352
+ status: str
353
+ attempts: int
354
+ next_attempt_at: datetime
355
+ last_status_code: Optional[int]
356
+ last_error: Optional[str]
357
+ created_at: datetime
358
+ delivered_at: Optional[datetime]
359
+
360
+ @classmethod
361
+ def _from_json(cls, data: Dict[str, Any]) -> "WebhookDelivery":
362
+ delivered = data.get("delivered_at")
363
+ return cls(
364
+ id=data["id"],
365
+ event_type=data["event_type"],
366
+ status=data["status"],
367
+ attempts=data["attempts"],
368
+ next_attempt_at=_parse_dt(data["next_attempt_at"]),
369
+ last_status_code=data.get("last_status_code"),
370
+ last_error=data.get("last_error"),
371
+ created_at=_parse_dt(data["created_at"]),
372
+ delivered_at=_parse_dt(delivered) if delivered else None,
373
+ )
374
+
375
+
376
+ def verify_webhook_signature(
377
+ payload: bytes,
378
+ header: str,
379
+ secret: str,
380
+ tolerance: float = 300.0,
381
+ now: Optional[float] = None,
382
+ ) -> bool:
383
+ """Verify an ``X-MailFixture-Signature`` header against the raw request
384
+ body: ``t=<unix>,v1=<hex hmac-sha256(secret, "t.body")>``, Stripe-style.
385
+
386
+ ``tolerance`` bounds the timestamp age in seconds (replay protection);
387
+ ``now`` overrides the clock for tests. Constant-time comparison.
388
+ """
389
+ timestamp: Optional[int] = None
390
+ candidates: List[str] = []
391
+ for part in header.split(","):
392
+ name, _, value = part.strip().partition("=")
393
+ if name == "t" and value.isdigit():
394
+ timestamp = int(value)
395
+ elif name == "v1" and value:
396
+ candidates.append(value)
397
+ if timestamp is None or not candidates:
398
+ return False
399
+ clock = time.time() if now is None else now
400
+ if abs(clock - timestamp) > tolerance:
401
+ return False
402
+ mac = hmac.new(secret.encode(), f"{timestamp}.".encode() + payload, hashlib.sha256)
403
+ expected = mac.hexdigest()
404
+ return any(hmac.compare_digest(expected, candidate) for candidate in candidates)
405
+
406
+
407
+ @dataclass
408
+ class Inbox:
409
+ """A created inbox plus the wait helpers bound to it."""
410
+
411
+ id: str
412
+ email_address: str
413
+ local_part: str
414
+ domain: str
415
+ ttl_expires_at: Optional[datetime]
416
+ created_at: datetime
417
+ _client: "MailFixture" = field(repr=False, compare=False, default=None) # type: ignore[assignment]
418
+
419
+ @property
420
+ def address(self) -> str:
421
+ """Alias for `email_address`."""
422
+ return self.email_address
423
+
424
+ def messages(
425
+ self,
426
+ since: Optional[str] = None,
427
+ match: Optional[str] = None,
428
+ wait: Optional[int] = None,
429
+ ) -> List[MessageSummary]:
430
+ return self._client.list_messages(self.id, since=since, match=match, wait=wait)
431
+
432
+ def wait_for_message(
433
+ self, match: Optional[str] = None, timeout: float = 45.0
434
+ ) -> Message:
435
+ return self._client.wait_for_message(self.id, match=match, timeout=timeout)
436
+
437
+ def wait_for_otp(self, match: Optional[str] = None, timeout: float = 45.0) -> str:
438
+ return self._client.wait_for_otp(self.id, match=match, timeout=timeout)
439
+
440
+ def wait_for_link(
441
+ self,
442
+ kind: Optional[str] = None,
443
+ match: Optional[str] = None,
444
+ timeout: float = 45.0,
445
+ ) -> Link:
446
+ return self._client.wait_for_link(self.id, kind=kind, match=match, timeout=timeout)
447
+
448
+ def clear(self) -> None:
449
+ self._client.clear_inbox(self.id)
450
+
451
+ def delete(self) -> None:
452
+ self._client.delete_inbox(self.id)
453
+
454
+
455
+ class MailFixture:
456
+ """Synchronous MailFixture API client.
457
+
458
+ Args:
459
+ api_key: `mfx_` API key; defaults to `MAILFIXTURE_API_KEY`
460
+ (or `MAILFIXTURE_KEY`) from the environment.
461
+ base_url: API origin; defaults to `MAILFIXTURE_BASE_URL` or
462
+ the hosted API.
463
+ timeout: socket timeout for plain (non-long-poll) requests.
464
+ """
465
+
466
+ def __init__(
467
+ self,
468
+ api_key: Optional[str] = None,
469
+ base_url: Optional[str] = None,
470
+ timeout: float = 30.0,
471
+ ) -> None:
472
+ self.api_key = (
473
+ api_key
474
+ or os.environ.get("MAILFIXTURE_API_KEY")
475
+ or os.environ.get("MAILFIXTURE_KEY")
476
+ )
477
+ if not self.api_key:
478
+ raise MailFixtureError(
479
+ None,
480
+ "missing API key",
481
+ "pass api_key= or set MAILFIXTURE_API_KEY",
482
+ )
483
+ self.base_url = (
484
+ base_url or os.environ.get("MAILFIXTURE_BASE_URL") or DEFAULT_BASE_URL
485
+ ).rstrip("/")
486
+ self.timeout = timeout
487
+
488
+ # -- inboxes -----------------------------------------------------------
489
+
490
+ def create_inbox(
491
+ self,
492
+ local_part: Optional[str] = None,
493
+ domain: Optional[str] = None,
494
+ ttl_seconds: Optional[int] = None,
495
+ ) -> Inbox:
496
+ """Create an inbox. With no arguments: a random address on the
497
+ shared domain, kept until you delete it."""
498
+ body: Dict[str, Any] = {}
499
+ if local_part is not None:
500
+ body["local_part"] = local_part
501
+ if domain is not None:
502
+ body["domain"] = domain
503
+ if ttl_seconds is not None:
504
+ body["ttl_seconds"] = ttl_seconds
505
+ data = self._request("POST", "/v1/inboxes", body=body)
506
+ ttl = data.get("ttl_expires_at")
507
+ return Inbox(
508
+ id=data["id"],
509
+ email_address=data["email_address"],
510
+ local_part=data["local_part"],
511
+ domain=data["domain"],
512
+ ttl_expires_at=_parse_dt(ttl) if ttl else None,
513
+ created_at=_parse_dt(data["created_at"]),
514
+ _client=self,
515
+ )
516
+
517
+ def list_inboxes(self) -> List[InboxSummary]:
518
+ data = self._request("GET", "/v1/inboxes")
519
+ return [InboxSummary._from_json(i) for i in data["inboxes"]]
520
+
521
+ def delete_inbox(self, inbox_id: str) -> None:
522
+ self._request("DELETE", f"/v1/inboxes/{inbox_id}")
523
+
524
+ def clear_inbox(self, inbox_id: str) -> None:
525
+ """Drop an inbox's messages but keep the address."""
526
+ self._request("POST", f"/v1/inboxes/{inbox_id}/clear")
527
+
528
+ # -- messages ----------------------------------------------------------
529
+
530
+ def list_messages(
531
+ self,
532
+ inbox_id: str,
533
+ since: Optional[str] = None,
534
+ match: Optional[str] = None,
535
+ wait: Optional[int] = None,
536
+ ) -> List[MessageSummary]:
537
+ """List an inbox's messages, oldest first.
538
+
539
+ `match` filters with `subject:`, `from:` or `to:` prefixes (a bare
540
+ term matches the subject). `wait` long-polls up to that many
541
+ seconds (server ceiling 60) until a matching message arrives.
542
+ """
543
+ params: Dict[str, str] = {}
544
+ if since is not None:
545
+ params["since"] = since
546
+ if match is not None:
547
+ params["match"] = match
548
+ if wait is not None:
549
+ params["wait"] = str(wait)
550
+ data = self._request(
551
+ "GET",
552
+ f"/v1/inboxes/{inbox_id}/messages",
553
+ params=params,
554
+ read_timeout=(wait or 0) + 15,
555
+ )
556
+ return [MessageSummary._from_json(m) for m in data["messages"]]
557
+
558
+ def get_message(self, message_id: str) -> Message:
559
+ return Message._from_json(self._request("GET", f"/v1/messages/{message_id}"))
560
+
561
+ def get_otp(self, message_id: str) -> Otp:
562
+ return Otp._from_json(self._request("GET", f"/v1/messages/{message_id}/otp"))
563
+
564
+ def get_links(self, message_id: str) -> List[Link]:
565
+ data = self._request("GET", f"/v1/messages/{message_id}/links")
566
+ return [Link._from_json(l) for l in data["links"]]
567
+
568
+ def download_attachment(self, message_id: str, index: int) -> bytes:
569
+ """Decoded bytes of one attachment (index into `Message.attachments`)."""
570
+ return self._request_raw("GET", f"/v1/messages/{message_id}/attachments/{index}")
571
+
572
+ # -- wait helpers (the point of the SDK) --------------------------------
573
+
574
+ def wait_for_message(
575
+ self,
576
+ inbox_id: str,
577
+ match: Optional[str] = None,
578
+ timeout: float = 45.0,
579
+ since: Optional[str] = None,
580
+ ) -> Message:
581
+ """Long-poll until a matching message arrives; returns the full
582
+ message. Raises `MailFixtureTimeout` after `timeout` seconds."""
583
+ deadline = time.monotonic() + timeout
584
+ cursor = since
585
+ while True:
586
+ remaining = deadline - time.monotonic()
587
+ summaries = self._poll(inbox_id, cursor, match, remaining)
588
+ if summaries:
589
+ return self.get_message(summaries[0].id)
590
+ if deadline - time.monotonic() <= 0:
591
+ raise MailFixtureTimeout(
592
+ f"no message matching {match!r} within {timeout}s"
593
+ )
594
+
595
+ def wait_for_otp(
596
+ self, inbox_id: str, match: Optional[str] = None, timeout: float = 45.0
597
+ ) -> str:
598
+ """Long-poll until a message with an extracted OTP arrives and
599
+ return the best candidate. Messages without an OTP are skipped."""
600
+ for message_id in self._new_messages(inbox_id, match, timeout):
601
+ best = self.get_otp(message_id).best
602
+ if best:
603
+ return best
604
+ raise MailFixtureTimeout(f"no message with an OTP within {timeout}s")
605
+
606
+ def wait_for_link(
607
+ self,
608
+ inbox_id: str,
609
+ kind: Optional[str] = None,
610
+ match: Optional[str] = None,
611
+ timeout: float = 45.0,
612
+ ) -> Link:
613
+ """Long-poll until a message containing a link arrives and return
614
+ the first link — optionally only of `kind`
615
+ (verify | reset | unsubscribe | other)."""
616
+ for message_id in self._new_messages(inbox_id, match, timeout):
617
+ for link in self.get_links(message_id):
618
+ if kind is None or link.kind == kind:
619
+ return link
620
+ raise MailFixtureTimeout(
621
+ f"no message with a {kind or 'any'} link within {timeout}s"
622
+ )
623
+
624
+ def _new_messages(self, inbox_id: str, match: Optional[str], timeout: float):
625
+ """Yield message ids as they arrive until the deadline passes."""
626
+ deadline = time.monotonic() + timeout
627
+ cursor: Optional[str] = None
628
+ while True:
629
+ remaining = deadline - time.monotonic()
630
+ summaries = self._poll(inbox_id, cursor, match, remaining)
631
+ for summary in summaries:
632
+ cursor = summary.received_at_raw
633
+ yield summary.id
634
+ if not summaries and deadline - time.monotonic() <= 0:
635
+ return
636
+
637
+ def _poll(
638
+ self,
639
+ inbox_id: str,
640
+ since: Optional[str],
641
+ match: Optional[str],
642
+ remaining: float,
643
+ ) -> List[MessageSummary]:
644
+ """One long-poll chunk, rate-limit aware."""
645
+ wait = max(0, min(math.ceil(remaining), MAX_SERVER_WAIT))
646
+ try:
647
+ return self.list_messages(inbox_id, since=since, match=match, wait=wait)
648
+ except MailFixtureError as err:
649
+ if err.status == 429 and err.retry_after and err.retry_after < remaining:
650
+ time.sleep(err.retry_after)
651
+ return []
652
+ raise
653
+
654
+ # -- domains -----------------------------------------------------------
655
+
656
+ def create_domain(self, fqdn: str) -> Domain:
657
+ """Register a custom domain; `Domain.setup` carries the TXT + MX
658
+ records to publish."""
659
+ return Domain._from_json(self._request("POST", "/v1/domains", body={"fqdn": fqdn}))
660
+
661
+ def list_domains(self) -> List[Domain]:
662
+ data = self._request("GET", "/v1/domains")
663
+ return [Domain._from_json(d) for d in data["domains"]]
664
+
665
+ def verify_domain(self, domain_id: str) -> DomainVerification:
666
+ data = self._request("POST", f"/v1/domains/{domain_id}/verify")
667
+ verified = data.get("verified_at")
668
+ mx_ok_at = data.get("mx_ok_at")
669
+ return DomainVerification(
670
+ id=data["id"],
671
+ fqdn=data["fqdn"],
672
+ txt_ok=data["txt_ok"],
673
+ mx_ok=data["mx_ok"],
674
+ verified_at=_parse_dt(verified) if verified else None,
675
+ mx_ok_at=_parse_dt(mx_ok_at) if mx_ok_at else None,
676
+ setup=data.get("setup") or {},
677
+ )
678
+
679
+ def delete_domain(self, domain_id: str) -> None:
680
+ self._request("DELETE", f"/v1/domains/{domain_id}")
681
+
682
+ # -- API keys ----------------------------------------------------------
683
+
684
+ def create_key(self, label: Optional[str] = None) -> CreatedApiKey:
685
+ body = {"label": label} if label else {}
686
+ data = self._request("POST", "/v1/keys", body=body)
687
+ return CreatedApiKey(id=data["id"], prefix=data["prefix"], key=data["key"])
688
+
689
+ def list_keys(self) -> List[ApiKey]:
690
+ data = self._request("GET", "/v1/keys")
691
+ return [
692
+ ApiKey(
693
+ id=k["id"],
694
+ prefix=k["prefix"],
695
+ label=k.get("label"),
696
+ created_at=_parse_dt(k["created_at"]),
697
+ revoked_at=_parse_dt(k["revoked_at"]) if k.get("revoked_at") else None,
698
+ )
699
+ for k in data["keys"]
700
+ ]
701
+
702
+ def revoke_key(self, key_id: str) -> None:
703
+ self._request("DELETE", f"/v1/keys/{key_id}")
704
+
705
+ # -- webhooks (paid plans) ----------------------------------------------
706
+
707
+ def create_webhook(self, url: str, description: Optional[str] = None) -> CreatedWebhook:
708
+ """Register an HTTPS endpoint for `message.received` and
709
+ `domain.verified` events. `CreatedWebhook.secret` is shown once."""
710
+ body: Dict[str, Any] = {"url": url}
711
+ if description:
712
+ body["description"] = description
713
+ data = self._request("POST", "/v1/webhooks", body=body)
714
+ return CreatedWebhook(
715
+ id=data["id"],
716
+ url=data["url"],
717
+ description=data.get("description"),
718
+ secret=data["secret"],
719
+ created_at=_parse_dt(data["created_at"]),
720
+ )
721
+
722
+ def list_webhooks(self) -> List[Webhook]:
723
+ data = self._request("GET", "/v1/webhooks")
724
+ return [Webhook._from_json(w) for w in data["webhooks"]]
725
+
726
+ def delete_webhook(self, webhook_id: str) -> None:
727
+ self._request("DELETE", f"/v1/webhooks/{webhook_id}")
728
+
729
+ def list_webhook_deliveries(self, webhook_id: str, limit: int = 50) -> List[WebhookDelivery]:
730
+ data = self._request(
731
+ "GET", f"/v1/webhooks/{webhook_id}/deliveries", params={"limit": str(limit)}
732
+ )
733
+ return [WebhookDelivery._from_json(d) for d in data["deliveries"]]
734
+
735
+ def test_webhook(self, webhook_id: str) -> str:
736
+ """Queue an `endpoint.test` event; returns the delivery id."""
737
+ data = self._request("POST", f"/v1/webhooks/{webhook_id}/test")
738
+ return data["delivery_id"]
739
+
740
+ def enable_webhook(self, webhook_id: str) -> None:
741
+ """Clear an auto-disabled endpoint so deliveries resume."""
742
+ self._request("POST", f"/v1/webhooks/{webhook_id}/enable")
743
+
744
+ # -- transport ---------------------------------------------------------
745
+
746
+ def _request(
747
+ self,
748
+ method: str,
749
+ path: str,
750
+ body: Optional[Dict[str, Any]] = None,
751
+ params: Optional[Dict[str, str]] = None,
752
+ read_timeout: Optional[float] = None,
753
+ ) -> Dict[str, Any]:
754
+ raw = self._request_raw(
755
+ method, path, body=body, params=params, read_timeout=read_timeout
756
+ )
757
+ if not raw:
758
+ return {}
759
+ return json.loads(raw)
760
+
761
+ def _request_raw(
762
+ self,
763
+ method: str,
764
+ path: str,
765
+ body: Optional[Dict[str, Any]] = None,
766
+ params: Optional[Dict[str, str]] = None,
767
+ read_timeout: Optional[float] = None,
768
+ ) -> bytes:
769
+ url = self.base_url + path
770
+ if params:
771
+ url += "?" + urllib.parse.urlencode(params)
772
+ data = json.dumps(body).encode() if body is not None else None
773
+ req = urllib.request.Request(url, data=data, method=method)
774
+ req.add_header("Authorization", f"Bearer {self.api_key}")
775
+ req.add_header("User-Agent", f"mailfixture-python/{__version__}")
776
+ if data is not None:
777
+ req.add_header("Content-Type", "application/json")
778
+ try:
779
+ with urllib.request.urlopen(
780
+ req, timeout=max(read_timeout or 0, self.timeout)
781
+ ) as res:
782
+ return res.read()
783
+ except urllib.error.HTTPError as err:
784
+ raise self._problem(err) from None
785
+ except urllib.error.URLError as err:
786
+ raise MailFixtureError(None, "connection error", str(err.reason)) from None
787
+
788
+ @staticmethod
789
+ def _problem(err: urllib.error.HTTPError) -> MailFixtureError:
790
+ title, detail = "error", None
791
+ try:
792
+ problem = json.loads(err.read())
793
+ title = problem.get("title", title)
794
+ detail = problem.get("detail")
795
+ except (ValueError, OSError):
796
+ pass
797
+ retry_after = None
798
+ header = err.headers.get("Retry-After") if err.headers else None
799
+ if header and header.isdigit():
800
+ retry_after = int(header)
801
+ return MailFixtureError(err.code, title, detail, retry_after)
File without changes
@@ -0,0 +1,297 @@
1
+ """Unit tests against a stub of the real /v1 wire protocol.
2
+
3
+ The stub mirrors the shapes the axum handlers actually serialize
4
+ (see api/src/{inboxes,messages,keys_api,domains}.rs); if the API
5
+ changes shape, change the stub in the same commit.
6
+ """
7
+
8
+ import json
9
+ import threading
10
+ import unittest
11
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
12
+ from urllib.parse import parse_qs, urlparse
13
+
14
+ from mailfixture import (
15
+ MailFixture,
16
+ MailFixtureError,
17
+ MailFixtureTimeout,
18
+ verify_webhook_signature,
19
+ )
20
+
21
+ INBOX_ID = "0d4cc81e-73aa-4efc-a5a2-3d9166a95a3c"
22
+ MSG_ID = "cb26ac0e-2ec9-40b8-a5f0-9f3a55b5f2ff"
23
+ WH_ID = "5b8f7de2-1c34-4e11-9a95-6a7f2f0f4c21"
24
+ WH_DELIVERY_ID = "7c1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b"
25
+ T0 = "2026-07-05T12:00:00.000001Z"
26
+ T1 = "2026-07-05T12:00:05.000002Z"
27
+
28
+ MESSAGE_SUMMARY = {
29
+ "id": MSG_ID,
30
+ "received_at": T1,
31
+ "from_addr": "noreply@acme.test",
32
+ "to_addrs": ["x7f3@acme.mxsink.sh"],
33
+ "subject": "Your Acme code is 482913",
34
+ }
35
+
36
+ MESSAGE_DETAIL = {
37
+ **MESSAGE_SUMMARY,
38
+ "inbox_id": INBOX_ID,
39
+ "text_body": "Your code is 482913",
40
+ "html_body": None,
41
+ "headers": {"message-id": "<1@acme.test>"},
42
+ "extracted": {
43
+ "otp": {"best": "482913", "candidates": [{"value": "482913", "score": 9.5}]},
44
+ "links": [
45
+ {"url": "https://acme.test/verify?t=abc", "text": "Verify", "class": "verify"}
46
+ ],
47
+ },
48
+ "attachments": [
49
+ {"filename": "report.pdf", "content_type": "application/pdf", "size": 123, "inline": False}
50
+ ],
51
+ "raw_size": 2048,
52
+ "expires_at": "2026-07-08T12:00:05Z",
53
+ }
54
+
55
+
56
+ class Stub(BaseHTTPRequestHandler):
57
+ # Per-server-instance state, reset by each test via make_server().
58
+ state = None
59
+
60
+ def log_message(self, *args):
61
+ pass
62
+
63
+ def _json(self, status, payload, headers=None):
64
+ body = json.dumps(payload).encode()
65
+ self.send_response(status)
66
+ self.send_header("Content-Type", "application/json")
67
+ for k, v in (headers or {}).items():
68
+ self.send_header(k, v)
69
+ self.end_headers()
70
+ self.wfile.write(body)
71
+
72
+ def _problem(self, status, title, detail, headers=None):
73
+ self._json(status, {"type": "about:blank", "title": title, "status": status, "detail": detail}, headers)
74
+
75
+ def do_POST(self):
76
+ path = urlparse(self.path).path
77
+ length = int(self.headers.get("Content-Length") or 0)
78
+ body = json.loads(self.rfile.read(length)) if length else {}
79
+ self.state["requests"].append(("POST", self.path, body))
80
+
81
+ if path == "/v1/inboxes":
82
+ self._json(201, {
83
+ "id": INBOX_ID,
84
+ "email_address": "x7f3@acme.mxsink.sh",
85
+ "local_part": "x7f3",
86
+ "domain": "acme.mxsink.sh",
87
+ "ttl_expires_at": None,
88
+ "created_at": T0,
89
+ })
90
+ elif path == f"/v1/inboxes/{INBOX_ID}/clear":
91
+ self.send_response(204)
92
+ self.end_headers()
93
+ elif path == "/v1/keys":
94
+ self._json(201, {"id": MSG_ID, "prefix": "mfx_ab12", "key": "mfx_ab12_secret"})
95
+ elif path == "/v1/webhooks":
96
+ self._json(201, {
97
+ "id": WH_ID, "url": body["url"], "description": body.get("description"),
98
+ "secret": "whsec_stubsecretstubsecretstubsecret1", "created_at": T0,
99
+ })
100
+ elif path == f"/v1/webhooks/{WH_ID}/test":
101
+ self._json(202, {"delivery_id": WH_DELIVERY_ID})
102
+ elif path == f"/v1/webhooks/{WH_ID}/enable":
103
+ self.send_response(204)
104
+ self.end_headers()
105
+ elif path == "/v1/domains":
106
+ self._json(201, {
107
+ "id": MSG_ID, "fqdn": body["fqdn"], "kind": "custom",
108
+ "verify_txt_token": "tok", "verified_at": None, "mx_ok_at": None,
109
+ "created_at": T0,
110
+ "setup": {"txt": {"name": "_mfx." + body["fqdn"], "value": "mfx-verify=tok"},
111
+ "mx": {"name": body["fqdn"], "value": "mx.mailfixture.com", "priority": 10}},
112
+ })
113
+ else:
114
+ self._problem(404, "not found", "no such route")
115
+
116
+ def do_GET(self):
117
+ parsed = urlparse(self.path)
118
+ query = parse_qs(parsed.query)
119
+ self.state["requests"].append(("GET", self.path, None))
120
+
121
+ if parsed.path == f"/v1/inboxes/{INBOX_ID}/messages":
122
+ polls = self.state["polls"]
123
+ self.state["polls"] += 1
124
+ deliver_on = self.state.get("deliver_on_poll", 0)
125
+ since = query.get("since", [None])[0]
126
+ if polls >= deliver_on and since != T1:
127
+ self._json(200, {"messages": [MESSAGE_SUMMARY]})
128
+ elif self.state.get("rate_limit_first") and polls == 0:
129
+ self._problem(429, "too many requests", "slow down", {"Retry-After": "1"})
130
+ else:
131
+ self._json(200, {"messages": []})
132
+ elif parsed.path == f"/v1/messages/{MSG_ID}":
133
+ self._json(200, MESSAGE_DETAIL)
134
+ elif parsed.path == f"/v1/messages/{MSG_ID}/otp":
135
+ self._json(200, MESSAGE_DETAIL["extracted"]["otp"])
136
+ elif parsed.path == f"/v1/messages/{MSG_ID}/links":
137
+ self._json(200, {"links": MESSAGE_DETAIL["extracted"]["links"]})
138
+ elif parsed.path == "/v1/webhooks":
139
+ self._json(200, {"webhooks": [{
140
+ "id": WH_ID, "url": "https://ci.acme.dev/hooks", "description": "ci",
141
+ "secret_hint": "whsec_…ret1", "consecutive_failures": 0,
142
+ "disabled_at": None, "disabled_reason": None, "created_at": T0,
143
+ }]})
144
+ elif parsed.path == f"/v1/webhooks/{WH_ID}/deliveries":
145
+ self._json(200, {"deliveries": [{
146
+ "id": WH_DELIVERY_ID, "event_type": "message.received",
147
+ "status": "succeeded", "attempts": 2, "next_attempt_at": T1,
148
+ "last_status_code": 200, "last_error": None,
149
+ "created_at": T0, "delivered_at": T1,
150
+ }]})
151
+ elif parsed.path == f"/v1/messages/{MSG_ID}/attachments/0":
152
+ self.send_response(200)
153
+ self.send_header("Content-Type", "application/octet-stream")
154
+ self.end_headers()
155
+ self.wfile.write(b"%PDF-fake")
156
+ else:
157
+ self._problem(404, "not found", "no such route")
158
+
159
+ def do_DELETE(self):
160
+ self.state["requests"].append(("DELETE", self.path, None))
161
+ self.send_response(204)
162
+ self.end_headers()
163
+
164
+
165
+ class ClientTest(unittest.TestCase):
166
+ def setUp(self):
167
+ state = {"requests": [], "polls": 0}
168
+ handler = type("H", (Stub,), {"state": state})
169
+ self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
170
+ self.state = state
171
+ threading.Thread(target=self.server.serve_forever, daemon=True).start()
172
+ self.client = MailFixture(
173
+ api_key="mfx_test",
174
+ base_url=f"http://127.0.0.1:{self.server.server_address[1]}",
175
+ )
176
+
177
+ def tearDown(self):
178
+ self.server.shutdown()
179
+ self.server.server_close()
180
+
181
+ def test_create_inbox_and_fields(self):
182
+ inbox = self.client.create_inbox(local_part="x7f3", ttl_seconds=900)
183
+ self.assertEqual(inbox.email_address, "x7f3@acme.mxsink.sh")
184
+ self.assertEqual(inbox.address, inbox.email_address)
185
+ method, path, body = self.state["requests"][0]
186
+ self.assertEqual(body, {"local_part": "x7f3", "ttl_seconds": 900})
187
+
188
+ def test_wait_for_otp_immediate(self):
189
+ inbox = self.client.create_inbox()
190
+ otp = inbox.wait_for_otp(timeout=5)
191
+ self.assertEqual(otp, "482913")
192
+
193
+ def test_wait_for_otp_after_empty_polls(self):
194
+ self.state["deliver_on_poll"] = 2
195
+ otp = self.client.wait_for_otp(INBOX_ID, timeout=10)
196
+ self.assertEqual(otp, "482913")
197
+ self.assertGreaterEqual(self.state["polls"], 3)
198
+
199
+ def test_wait_for_message_returns_full_detail(self):
200
+ message = self.client.wait_for_message(INBOX_ID, match="subject:Acme", timeout=5)
201
+ self.assertEqual(message.otp, "482913")
202
+ self.assertEqual(message.text_body, "Your code is 482913")
203
+ self.assertEqual(message.attachments[0].filename, "report.pdf")
204
+ poll_path = next(p for m, p, _ in self.state["requests"] if "/messages?" in p)
205
+ self.assertIn("match=subject%3AAcme", poll_path)
206
+ self.assertIn("wait=5", poll_path)
207
+
208
+ def test_wait_for_link_filters_kind(self):
209
+ link = self.client.wait_for_link(INBOX_ID, kind="verify", timeout=5)
210
+ self.assertEqual(link.url, "https://acme.test/verify?t=abc")
211
+ self.assertEqual(str(link), link.url)
212
+ with self.assertRaises(MailFixtureTimeout):
213
+ self.client.wait_for_link(INBOX_ID, kind="reset", timeout=0.2)
214
+
215
+ def test_timeout_raises(self):
216
+ self.state["deliver_on_poll"] = 10_000
217
+ with self.assertRaises(MailFixtureTimeout):
218
+ self.client.wait_for_message(INBOX_ID, timeout=0.2)
219
+
220
+ def test_rate_limit_retry_during_wait(self):
221
+ self.state["rate_limit_first"] = True
222
+ self.state["deliver_on_poll"] = 1
223
+ otp = self.client.wait_for_otp(INBOX_ID, timeout=10)
224
+ self.assertEqual(otp, "482913")
225
+
226
+ def test_problem_json_error(self):
227
+ with self.assertRaises(MailFixtureError) as ctx:
228
+ self.client.get_message("00000000-0000-0000-0000-000000000000")
229
+ self.assertEqual(ctx.exception.status, 404)
230
+ self.assertEqual(ctx.exception.title, "not found")
231
+
232
+ def test_attachment_download(self):
233
+ data = self.client.download_attachment(MSG_ID, 0)
234
+ self.assertEqual(data, b"%PDF-fake")
235
+
236
+ def test_keys_and_domains(self):
237
+ created = self.client.create_key(label="ci")
238
+ self.assertTrue(created.key.startswith("mfx_"))
239
+ domain = self.client.create_domain("tests.acme.dev")
240
+ self.assertEqual(domain.setup["mx"]["priority"], 10)
241
+ self.client.delete_domain(domain.id)
242
+ self.client.revoke_key(created.id)
243
+
244
+ def test_webhooks_crud_and_deliveries(self):
245
+ created = self.client.create_webhook("https://ci.acme.dev/hooks", description="ci")
246
+ self.assertTrue(created.secret.startswith("whsec_"))
247
+ method, path, body = self.state["requests"][0]
248
+ self.assertEqual(body, {"url": "https://ci.acme.dev/hooks", "description": "ci"})
249
+
250
+ hooks = self.client.list_webhooks()
251
+ self.assertEqual(hooks[0].secret_hint, "whsec_…ret1")
252
+ self.assertIsNone(hooks[0].disabled_at)
253
+
254
+ delivery_id = self.client.test_webhook(created.id)
255
+ self.assertEqual(delivery_id, WH_DELIVERY_ID)
256
+
257
+ deliveries = self.client.list_webhook_deliveries(created.id, limit=5)
258
+ self.assertEqual(deliveries[0].status, "succeeded")
259
+ self.assertEqual(deliveries[0].attempts, 2)
260
+ query = next(p for m, p, _ in self.state["requests"] if "/deliveries" in p)
261
+ self.assertIn("limit=5", query)
262
+
263
+ self.client.enable_webhook(created.id)
264
+ self.client.delete_webhook(created.id)
265
+ self.assertEqual(self.state["requests"][-1][0], "DELETE")
266
+
267
+ def test_verify_webhook_signature(self):
268
+ import hashlib
269
+ import hmac as hmac_mod
270
+
271
+ secret = "whsec_testsecret"
272
+ payload = b'{"id":"d-1","type":"endpoint.test"}'
273
+ t = 1_751_700_000
274
+ sig = hmac_mod.new(secret.encode(), f"{t}.".encode() + payload, hashlib.sha256).hexdigest()
275
+ header = f"t={t},v1={sig}"
276
+
277
+ self.assertTrue(verify_webhook_signature(payload, header, secret, now=t + 10))
278
+ # wrong secret / tampered body / stale timestamp / garbage header
279
+ self.assertFalse(verify_webhook_signature(payload, header, "whsec_other", now=t + 10))
280
+ self.assertFalse(verify_webhook_signature(b"{}", header, secret, now=t + 10))
281
+ self.assertFalse(verify_webhook_signature(payload, header, secret, now=t + 3600))
282
+ self.assertFalse(verify_webhook_signature(payload, "v1=deadbeef", secret, now=t))
283
+
284
+ def test_missing_api_key(self):
285
+ import os
286
+ old = {k: os.environ.pop(k, None) for k in ("MAILFIXTURE_API_KEY", "MAILFIXTURE_KEY")}
287
+ try:
288
+ with self.assertRaises(MailFixtureError):
289
+ MailFixture()
290
+ finally:
291
+ for k, v in old.items():
292
+ if v is not None:
293
+ os.environ[k] = v
294
+
295
+
296
+ if __name__ == "__main__":
297
+ unittest.main()