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.
- mailfixture-0.1.0/.gitignore +3 -0
- mailfixture-0.1.0/LICENSE +21 -0
- mailfixture-0.1.0/PKG-INFO +90 -0
- mailfixture-0.1.0/README.md +72 -0
- mailfixture-0.1.0/pyproject.toml +28 -0
- mailfixture-0.1.0/src/mailfixture/__init__.py +801 -0
- mailfixture-0.1.0/src/mailfixture/py.typed +0 -0
- mailfixture-0.1.0/tests/test_client.py +297 -0
|
@@ -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()
|