mailfixture 0.2.0__tar.gz → 0.4.1__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.2.0 → mailfixture-0.4.1}/PKG-INFO +1 -1
- {mailfixture-0.2.0 → mailfixture-0.4.1}/pyproject.toml +1 -1
- {mailfixture-0.2.0 → mailfixture-0.4.1}/src/mailfixture/__init__.py +168 -1
- {mailfixture-0.2.0 → mailfixture-0.4.1}/tests/test_client.py +64 -1
- {mailfixture-0.2.0 → mailfixture-0.4.1}/.gitignore +0 -0
- {mailfixture-0.2.0 → mailfixture-0.4.1}/LICENSE +0 -0
- {mailfixture-0.2.0 → mailfixture-0.4.1}/README.md +0 -0
- {mailfixture-0.2.0 → mailfixture-0.4.1}/src/mailfixture/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mailfixture
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Email fixtures for your test suite — programmatic inboxes, long-polling, first-class OTP and link extraction.
|
|
5
5
|
Project-URL: Homepage, https://mailfixture.com
|
|
6
6
|
Project-URL: Documentation, https://mailfixture.com/docs
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mailfixture"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.1"
|
|
8
8
|
description = "Email fixtures for your test suite — programmatic inboxes, long-polling, first-class OTP and link extraction."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -42,6 +42,11 @@ __all__ = [
|
|
|
42
42
|
"Otp",
|
|
43
43
|
"OtpCandidate",
|
|
44
44
|
"Link",
|
|
45
|
+
"Auth",
|
|
46
|
+
"SpfAuth",
|
|
47
|
+
"DkimAuth",
|
|
48
|
+
"DmarcAuth",
|
|
49
|
+
"FollowResult",
|
|
45
50
|
"Attachment",
|
|
46
51
|
"Domain",
|
|
47
52
|
"DomainVerification",
|
|
@@ -55,7 +60,7 @@ __all__ = [
|
|
|
55
60
|
"MailFixtureTimeout",
|
|
56
61
|
]
|
|
57
62
|
|
|
58
|
-
__version__ = "0.
|
|
63
|
+
__version__ = "0.4.1"
|
|
59
64
|
|
|
60
65
|
DEFAULT_BASE_URL = "https://api.mailfixture.com"
|
|
61
66
|
#: Server-side ceiling for one long-poll request; the client chains
|
|
@@ -138,6 +143,103 @@ class Link:
|
|
|
138
143
|
return cls(url=data["url"], text=data.get("text"), kind=data.get("class", "other"))
|
|
139
144
|
|
|
140
145
|
|
|
146
|
+
@dataclass
|
|
147
|
+
class SpfAuth:
|
|
148
|
+
"""SPF verdict for the delivering session. `result` uses the RFC 8601
|
|
149
|
+
strings: pass | fail | softfail | neutral | none | temperror |
|
|
150
|
+
permerror."""
|
|
151
|
+
|
|
152
|
+
result: str
|
|
153
|
+
#: The domain SPF was evaluated for (envelope sender, or HELO for
|
|
154
|
+
#: bounces).
|
|
155
|
+
domain: Optional[str]
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def _from_json(cls, data: Dict[str, Any]) -> "SpfAuth":
|
|
159
|
+
return cls(result=data.get("result", "none"), domain=data.get("domain"))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class DkimAuth:
|
|
164
|
+
"""One DKIM signature's verdict. A broken signature reports `neutral`
|
|
165
|
+
(RFC 6376 treats it as unsigned) with the reason in `error`."""
|
|
166
|
+
|
|
167
|
+
result: str
|
|
168
|
+
domain: Optional[str]
|
|
169
|
+
selector: Optional[str]
|
|
170
|
+
error: Optional[str]
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def _from_json(cls, data: Dict[str, Any]) -> "DkimAuth":
|
|
174
|
+
return cls(
|
|
175
|
+
result=data.get("result", "none"),
|
|
176
|
+
domain=data.get("domain"),
|
|
177
|
+
selector=data.get("selector"),
|
|
178
|
+
error=data.get("error"),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass
|
|
183
|
+
class DmarcAuth:
|
|
184
|
+
"""DMARC verdict: pass when either aligned mechanism passed; `none`
|
|
185
|
+
when the From domain publishes no record (then `policy` is None)."""
|
|
186
|
+
|
|
187
|
+
result: str
|
|
188
|
+
#: The RFC5322.From domain DMARC was evaluated for.
|
|
189
|
+
domain: Optional[str]
|
|
190
|
+
#: none | quarantine | reject — only when a record exists.
|
|
191
|
+
policy: Optional[str]
|
|
192
|
+
|
|
193
|
+
@classmethod
|
|
194
|
+
def _from_json(cls, data: Dict[str, Any]) -> "DmarcAuth":
|
|
195
|
+
return cls(
|
|
196
|
+
result=data.get("result", "none"),
|
|
197
|
+
domain=data.get("domain"),
|
|
198
|
+
policy=data.get("policy"),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass
|
|
203
|
+
class Auth:
|
|
204
|
+
"""Inbound SPF/DKIM/DMARC verification results for a message.
|
|
205
|
+
Unsigned mail has an empty `dkim` list."""
|
|
206
|
+
|
|
207
|
+
spf: SpfAuth
|
|
208
|
+
dkim: List[DkimAuth]
|
|
209
|
+
dmarc: DmarcAuth
|
|
210
|
+
|
|
211
|
+
@classmethod
|
|
212
|
+
def _from_json(cls, data: Dict[str, Any]) -> "Auth":
|
|
213
|
+
return cls(
|
|
214
|
+
spf=SpfAuth._from_json(data.get("spf") or {}),
|
|
215
|
+
dkim=[DkimAuth._from_json(d) for d in data.get("dkim") or []],
|
|
216
|
+
dmarc=DmarcAuth._from_json(data.get("dmarc") or {}),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@dataclass
|
|
221
|
+
class FollowResult:
|
|
222
|
+
"""Outcome of a server-side link follow: the final response after
|
|
223
|
+
redirects. The target's body is never returned."""
|
|
224
|
+
|
|
225
|
+
requested_url: str
|
|
226
|
+
final_url: str
|
|
227
|
+
status: int
|
|
228
|
+
redirects: int
|
|
229
|
+
#: Convenience: status was 2xx.
|
|
230
|
+
ok: bool
|
|
231
|
+
|
|
232
|
+
@classmethod
|
|
233
|
+
def _from_json(cls, data: Dict[str, Any]) -> "FollowResult":
|
|
234
|
+
return cls(
|
|
235
|
+
requested_url=data["requested_url"],
|
|
236
|
+
final_url=data["final_url"],
|
|
237
|
+
status=data["status"],
|
|
238
|
+
redirects=data["redirects"],
|
|
239
|
+
ok=data["ok"],
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
141
243
|
@dataclass
|
|
142
244
|
class Attachment:
|
|
143
245
|
filename: str
|
|
@@ -199,6 +301,13 @@ class Message:
|
|
|
199
301
|
def links(self) -> List[Link]:
|
|
200
302
|
return [Link._from_json(l) for l in self.extracted.get("links", [])]
|
|
201
303
|
|
|
304
|
+
@property
|
|
305
|
+
def auth(self) -> Optional[Auth]:
|
|
306
|
+
"""SPF/DKIM/DMARC verdicts. None = not verified (message predates
|
|
307
|
+
the feature or verification was disabled) — never "failed"."""
|
|
308
|
+
raw = self.extracted.get("auth")
|
|
309
|
+
return Auth._from_json(raw) if isinstance(raw, dict) else None
|
|
310
|
+
|
|
202
311
|
@classmethod
|
|
203
312
|
def _from_json(cls, data: Dict[str, Any]) -> "Message":
|
|
204
313
|
return cls(
|
|
@@ -440,6 +549,19 @@ class WebhookDelivery:
|
|
|
440
549
|
)
|
|
441
550
|
|
|
442
551
|
|
|
552
|
+
def _follow_body(
|
|
553
|
+
url: Optional[str], kind: Optional[str], index: Optional[int]
|
|
554
|
+
) -> Dict[str, Any]:
|
|
555
|
+
body: Dict[str, Any] = {}
|
|
556
|
+
if url is not None:
|
|
557
|
+
body["url"] = url
|
|
558
|
+
if kind is not None:
|
|
559
|
+
body["class"] = kind
|
|
560
|
+
if index is not None:
|
|
561
|
+
body["index"] = index
|
|
562
|
+
return body
|
|
563
|
+
|
|
564
|
+
|
|
443
565
|
def verify_webhook_signature(
|
|
444
566
|
payload: bytes,
|
|
445
567
|
header: str,
|
|
@@ -528,15 +650,21 @@ class PhoneNumber:
|
|
|
528
650
|
#: active | pending — a pending number becomes active within ~a minute.
|
|
529
651
|
status: str
|
|
530
652
|
created_at: datetime
|
|
653
|
+
#: Scheduled auto-release: set when a plan downgrade left the account
|
|
654
|
+
#: over its number cap. Upgrading (or releasing other numbers) before
|
|
655
|
+
#: this date keeps the number. None = not scheduled.
|
|
656
|
+
release_after: Optional[datetime] = None
|
|
531
657
|
_client: "MailFixture" = field(repr=False, compare=False, default=None) # type: ignore[assignment]
|
|
532
658
|
|
|
533
659
|
@classmethod
|
|
534
660
|
def _from_json(cls, data: Dict[str, Any], client: "MailFixture") -> "PhoneNumber":
|
|
661
|
+
release_after = data.get("release_after")
|
|
535
662
|
return cls(
|
|
536
663
|
id=data["id"],
|
|
537
664
|
phone_number=data["phone_number"],
|
|
538
665
|
status=data["status"],
|
|
539
666
|
created_at=_parse_dt(data["created_at"]),
|
|
667
|
+
release_after=_parse_dt(release_after) if release_after else None,
|
|
540
668
|
_client=client,
|
|
541
669
|
)
|
|
542
670
|
|
|
@@ -687,6 +815,29 @@ class MailFixture:
|
|
|
687
815
|
"""Decoded bytes of one attachment (index into `Message.attachments`)."""
|
|
688
816
|
return self._request_raw("GET", f"/v1/messages/{message_id}/attachments/{index}")
|
|
689
817
|
|
|
818
|
+
def follow_link(
|
|
819
|
+
self,
|
|
820
|
+
message_id: str,
|
|
821
|
+
url: Optional[str] = None,
|
|
822
|
+
kind: Optional[str] = None,
|
|
823
|
+
index: Optional[int] = None,
|
|
824
|
+
) -> FollowResult:
|
|
825
|
+
"""Server-side click: the API GETs one of the message's extracted
|
|
826
|
+
links (redirects followed, body discarded) and reports how the
|
|
827
|
+
target answered.
|
|
828
|
+
|
|
829
|
+
Pass at most one of `url` (exact extracted URL), `kind`
|
|
830
|
+
(verify | reset | unsubscribe | other; wire name `class`), or
|
|
831
|
+
`index`. Default: the first verify link.
|
|
832
|
+
"""
|
|
833
|
+
return FollowResult._from_json(
|
|
834
|
+
self._request(
|
|
835
|
+
"POST",
|
|
836
|
+
f"/v1/messages/{message_id}/links/follow",
|
|
837
|
+
body=_follow_body(url, kind, index),
|
|
838
|
+
)
|
|
839
|
+
)
|
|
840
|
+
|
|
690
841
|
# -- wait helpers (the point of the SDK) --------------------------------
|
|
691
842
|
|
|
692
843
|
def wait_for_message(
|
|
@@ -827,6 +978,22 @@ class MailFixture:
|
|
|
827
978
|
data = self._request("GET", f"/v1/sms/{sms_id}/links")
|
|
828
979
|
return [Link._from_json(l) for l in data["links"]]
|
|
829
980
|
|
|
981
|
+
def follow_sms_link(
|
|
982
|
+
self,
|
|
983
|
+
sms_id: str,
|
|
984
|
+
url: Optional[str] = None,
|
|
985
|
+
kind: Optional[str] = None,
|
|
986
|
+
index: Optional[int] = None,
|
|
987
|
+
) -> FollowResult:
|
|
988
|
+
"""`follow_link` for an SMS: same selectors, same result shape."""
|
|
989
|
+
return FollowResult._from_json(
|
|
990
|
+
self._request(
|
|
991
|
+
"POST",
|
|
992
|
+
f"/v1/sms/{sms_id}/links/follow",
|
|
993
|
+
body=_follow_body(url, kind, index),
|
|
994
|
+
)
|
|
995
|
+
)
|
|
996
|
+
|
|
830
997
|
def wait_for_sms(
|
|
831
998
|
self,
|
|
832
999
|
phone_number_id: str,
|
|
@@ -15,6 +15,7 @@ from mailfixture import (
|
|
|
15
15
|
MailFixture,
|
|
16
16
|
MailFixtureError,
|
|
17
17
|
MailFixtureTimeout,
|
|
18
|
+
Message,
|
|
18
19
|
verify_webhook_signature,
|
|
19
20
|
)
|
|
20
21
|
|
|
@@ -50,6 +51,11 @@ MESSAGE_DETAIL = {
|
|
|
50
51
|
"links": [
|
|
51
52
|
{"url": "https://acme.test/verify?t=abc", "text": "Verify", "class": "verify"}
|
|
52
53
|
],
|
|
54
|
+
"auth": {
|
|
55
|
+
"spf": {"result": "pass", "domain": "acme.test"},
|
|
56
|
+
"dkim": [{"result": "pass", "domain": "acme.test", "selector": "s1"}],
|
|
57
|
+
"dmarc": {"result": "pass", "domain": "acme.test", "policy": "reject"},
|
|
58
|
+
},
|
|
53
59
|
},
|
|
54
60
|
"attachments": [
|
|
55
61
|
{"filename": "report.pdf", "content_type": "application/pdf", "size": 123, "inline": False}
|
|
@@ -148,6 +154,22 @@ class Stub(BaseHTTPRequestHandler):
|
|
|
148
154
|
elif path == f"/v1/webhooks/{WH_ID}/enable":
|
|
149
155
|
self.send_response(204)
|
|
150
156
|
self.end_headers()
|
|
157
|
+
elif path == f"/v1/messages/{MSG_ID}/links/follow":
|
|
158
|
+
if body.get("class") == "reset":
|
|
159
|
+
self._problem(404, "not found",
|
|
160
|
+
"no reset link extracted (available classes: verify)")
|
|
161
|
+
else:
|
|
162
|
+
self._json(200, {
|
|
163
|
+
"requested_url": "https://acme.test/verify?t=abc",
|
|
164
|
+
"final_url": "https://acme.test/verified",
|
|
165
|
+
"status": 200, "redirects": 1, "ok": True,
|
|
166
|
+
})
|
|
167
|
+
elif path == f"/v1/sms/{SMS_ID}/links/follow":
|
|
168
|
+
self._json(200, {
|
|
169
|
+
"requested_url": "https://acme.test/verify?t=sms",
|
|
170
|
+
"final_url": "https://acme.test/verify?t=sms",
|
|
171
|
+
"status": 200, "redirects": 0, "ok": True,
|
|
172
|
+
})
|
|
151
173
|
elif path == "/v1/domains":
|
|
152
174
|
self._json(201, {
|
|
153
175
|
"id": MSG_ID, "fqdn": body["fqdn"], "kind": "custom",
|
|
@@ -187,7 +209,8 @@ class Stub(BaseHTTPRequestHandler):
|
|
|
187
209
|
else:
|
|
188
210
|
self._json(200, {"messages": [SMS_SUMMARY]})
|
|
189
211
|
elif parsed.path == "/v1/phone-numbers":
|
|
190
|
-
|
|
212
|
+
scheduled = dict(PHONE_NUMBER, release_after="2026-07-14T00:00:00Z")
|
|
213
|
+
self._json(200, {"phone_numbers": [scheduled]})
|
|
191
214
|
elif parsed.path == f"/v1/sms/{SMS_ID}":
|
|
192
215
|
self._json(200, SMS_DETAIL)
|
|
193
216
|
elif parsed.path == f"/v1/sms/{SMS_ID}/otp":
|
|
@@ -269,6 +292,14 @@ class ClientTest(unittest.TestCase):
|
|
|
269
292
|
self.assertEqual(message.text_body, "Your code is 482913")
|
|
270
293
|
self.assertEqual(message.attachments[0].filename, "report.pdf")
|
|
271
294
|
self.assertTrue(message.tls)
|
|
295
|
+
self.assertEqual(message.auth.spf.result, "pass")
|
|
296
|
+
self.assertEqual(message.auth.dkim[0].selector, "s1")
|
|
297
|
+
self.assertIsNone(message.auth.dkim[0].error)
|
|
298
|
+
self.assertEqual(message.auth.dmarc.policy, "reject")
|
|
299
|
+
# Absent auth key (pre-feature message) reads None, not a failure.
|
|
300
|
+
no_auth = dict(MESSAGE_DETAIL)
|
|
301
|
+
no_auth["extracted"] = {"otp": {"best": None, "candidates": []}, "links": []}
|
|
302
|
+
self.assertIsNone(Message._from_json(no_auth).auth)
|
|
272
303
|
poll_path = next(p for m, p, _ in self.state["requests"] if "/messages?" in p)
|
|
273
304
|
self.assertIn("match=subject%3AAcme", poll_path)
|
|
274
305
|
self.assertIn("wait=5", poll_path)
|
|
@@ -301,6 +332,34 @@ class ClientTest(unittest.TestCase):
|
|
|
301
332
|
data = self.client.download_attachment(MSG_ID, 0)
|
|
302
333
|
self.assertEqual(data, b"%PDF-fake")
|
|
303
334
|
|
|
335
|
+
def test_follow_link(self):
|
|
336
|
+
result = self.client.follow_link(MSG_ID)
|
|
337
|
+
self.assertTrue(result.ok)
|
|
338
|
+
self.assertEqual(result.status, 200)
|
|
339
|
+
self.assertEqual(result.redirects, 1)
|
|
340
|
+
self.assertEqual(result.final_url, "https://acme.test/verified")
|
|
341
|
+
method, path, body = self.state["requests"][0]
|
|
342
|
+
self.assertEqual((method, path), ("POST", f"/v1/messages/{MSG_ID}/links/follow"))
|
|
343
|
+
self.assertEqual(body, {})
|
|
344
|
+
# kind= travels as the wire field `class`
|
|
345
|
+
self.client.follow_link(MSG_ID, kind="verify")
|
|
346
|
+
self.assertEqual(self.state["requests"][-1][2], {"class": "verify"})
|
|
347
|
+
self.client.follow_link(MSG_ID, url="https://acme.test/verify?t=abc")
|
|
348
|
+
self.assertEqual(
|
|
349
|
+
self.state["requests"][-1][2], {"url": "https://acme.test/verify?t=abc"}
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
def test_follow_link_no_such_class(self):
|
|
353
|
+
with self.assertRaises(MailFixtureError) as ctx:
|
|
354
|
+
self.client.follow_link(MSG_ID, kind="reset")
|
|
355
|
+
self.assertEqual(ctx.exception.status, 404)
|
|
356
|
+
|
|
357
|
+
def test_follow_sms_link(self):
|
|
358
|
+
result = self.client.follow_sms_link(SMS_ID, index=0)
|
|
359
|
+
self.assertTrue(result.ok)
|
|
360
|
+
self.assertEqual(result.redirects, 0)
|
|
361
|
+
self.assertEqual(self.state["requests"][-1][2], {"index": 0})
|
|
362
|
+
|
|
304
363
|
def test_keys_and_domains(self):
|
|
305
364
|
created = self.client.create_key(label="ci")
|
|
306
365
|
self.assertTrue(created.key.startswith("mfx_"))
|
|
@@ -313,6 +372,7 @@ class ClientTest(unittest.TestCase):
|
|
|
313
372
|
number = self.client.create_phone_number()
|
|
314
373
|
self.assertEqual(number.phone_number, "+15551234567")
|
|
315
374
|
self.assertEqual(number.status, "active")
|
|
375
|
+
self.assertIsNone(number.release_after) # absent key = not scheduled
|
|
316
376
|
self.assertEqual(self.state["auth_header"], "Bearer mfx_test")
|
|
317
377
|
method, path, _ = self.state["requests"][0]
|
|
318
378
|
self.assertEqual((method, path), ("POST", "/v1/phone-numbers"))
|
|
@@ -321,6 +381,9 @@ class ClientTest(unittest.TestCase):
|
|
|
321
381
|
numbers = self.client.list_phone_numbers()
|
|
322
382
|
self.assertEqual(len(numbers), 1)
|
|
323
383
|
self.assertEqual(numbers[0].phone_number, "+15551234567")
|
|
384
|
+
self.assertEqual(
|
|
385
|
+
numbers[0].release_after.isoformat(), "2026-07-14T00:00:00+00:00"
|
|
386
|
+
)
|
|
324
387
|
numbers[0].release()
|
|
325
388
|
method, path, _ = self.state["requests"][-1]
|
|
326
389
|
self.assertEqual((method, path), ("DELETE", f"/v1/phone-numbers/{PN_ID}"))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|