mailfixture 0.2.0__tar.gz → 0.4.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.2.0 → mailfixture-0.4.0}/PKG-INFO +1 -1
- {mailfixture-0.2.0 → mailfixture-0.4.0}/pyproject.toml +1 -1
- {mailfixture-0.2.0 → mailfixture-0.4.0}/src/mailfixture/__init__.py +162 -1
- {mailfixture-0.2.0 → mailfixture-0.4.0}/tests/test_client.py +58 -0
- {mailfixture-0.2.0 → mailfixture-0.4.0}/.gitignore +0 -0
- {mailfixture-0.2.0 → mailfixture-0.4.0}/LICENSE +0 -0
- {mailfixture-0.2.0 → mailfixture-0.4.0}/README.md +0 -0
- {mailfixture-0.2.0 → mailfixture-0.4.0}/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.0
|
|
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.0"
|
|
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.0"
|
|
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,
|
|
@@ -687,6 +809,29 @@ class MailFixture:
|
|
|
687
809
|
"""Decoded bytes of one attachment (index into `Message.attachments`)."""
|
|
688
810
|
return self._request_raw("GET", f"/v1/messages/{message_id}/attachments/{index}")
|
|
689
811
|
|
|
812
|
+
def follow_link(
|
|
813
|
+
self,
|
|
814
|
+
message_id: str,
|
|
815
|
+
url: Optional[str] = None,
|
|
816
|
+
kind: Optional[str] = None,
|
|
817
|
+
index: Optional[int] = None,
|
|
818
|
+
) -> FollowResult:
|
|
819
|
+
"""Server-side click: the API GETs one of the message's extracted
|
|
820
|
+
links (redirects followed, body discarded) and reports how the
|
|
821
|
+
target answered.
|
|
822
|
+
|
|
823
|
+
Pass at most one of `url` (exact extracted URL), `kind`
|
|
824
|
+
(verify | reset | unsubscribe | other; wire name `class`), or
|
|
825
|
+
`index`. Default: the first verify link.
|
|
826
|
+
"""
|
|
827
|
+
return FollowResult._from_json(
|
|
828
|
+
self._request(
|
|
829
|
+
"POST",
|
|
830
|
+
f"/v1/messages/{message_id}/links/follow",
|
|
831
|
+
body=_follow_body(url, kind, index),
|
|
832
|
+
)
|
|
833
|
+
)
|
|
834
|
+
|
|
690
835
|
# -- wait helpers (the point of the SDK) --------------------------------
|
|
691
836
|
|
|
692
837
|
def wait_for_message(
|
|
@@ -827,6 +972,22 @@ class MailFixture:
|
|
|
827
972
|
data = self._request("GET", f"/v1/sms/{sms_id}/links")
|
|
828
973
|
return [Link._from_json(l) for l in data["links"]]
|
|
829
974
|
|
|
975
|
+
def follow_sms_link(
|
|
976
|
+
self,
|
|
977
|
+
sms_id: str,
|
|
978
|
+
url: Optional[str] = None,
|
|
979
|
+
kind: Optional[str] = None,
|
|
980
|
+
index: Optional[int] = None,
|
|
981
|
+
) -> FollowResult:
|
|
982
|
+
"""`follow_link` for an SMS: same selectors, same result shape."""
|
|
983
|
+
return FollowResult._from_json(
|
|
984
|
+
self._request(
|
|
985
|
+
"POST",
|
|
986
|
+
f"/v1/sms/{sms_id}/links/follow",
|
|
987
|
+
body=_follow_body(url, kind, index),
|
|
988
|
+
)
|
|
989
|
+
)
|
|
990
|
+
|
|
830
991
|
def wait_for_sms(
|
|
831
992
|
self,
|
|
832
993
|
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",
|
|
@@ -269,6 +291,14 @@ class ClientTest(unittest.TestCase):
|
|
|
269
291
|
self.assertEqual(message.text_body, "Your code is 482913")
|
|
270
292
|
self.assertEqual(message.attachments[0].filename, "report.pdf")
|
|
271
293
|
self.assertTrue(message.tls)
|
|
294
|
+
self.assertEqual(message.auth.spf.result, "pass")
|
|
295
|
+
self.assertEqual(message.auth.dkim[0].selector, "s1")
|
|
296
|
+
self.assertIsNone(message.auth.dkim[0].error)
|
|
297
|
+
self.assertEqual(message.auth.dmarc.policy, "reject")
|
|
298
|
+
# Absent auth key (pre-feature message) reads None, not a failure.
|
|
299
|
+
no_auth = dict(MESSAGE_DETAIL)
|
|
300
|
+
no_auth["extracted"] = {"otp": {"best": None, "candidates": []}, "links": []}
|
|
301
|
+
self.assertIsNone(Message._from_json(no_auth).auth)
|
|
272
302
|
poll_path = next(p for m, p, _ in self.state["requests"] if "/messages?" in p)
|
|
273
303
|
self.assertIn("match=subject%3AAcme", poll_path)
|
|
274
304
|
self.assertIn("wait=5", poll_path)
|
|
@@ -301,6 +331,34 @@ class ClientTest(unittest.TestCase):
|
|
|
301
331
|
data = self.client.download_attachment(MSG_ID, 0)
|
|
302
332
|
self.assertEqual(data, b"%PDF-fake")
|
|
303
333
|
|
|
334
|
+
def test_follow_link(self):
|
|
335
|
+
result = self.client.follow_link(MSG_ID)
|
|
336
|
+
self.assertTrue(result.ok)
|
|
337
|
+
self.assertEqual(result.status, 200)
|
|
338
|
+
self.assertEqual(result.redirects, 1)
|
|
339
|
+
self.assertEqual(result.final_url, "https://acme.test/verified")
|
|
340
|
+
method, path, body = self.state["requests"][0]
|
|
341
|
+
self.assertEqual((method, path), ("POST", f"/v1/messages/{MSG_ID}/links/follow"))
|
|
342
|
+
self.assertEqual(body, {})
|
|
343
|
+
# kind= travels as the wire field `class`
|
|
344
|
+
self.client.follow_link(MSG_ID, kind="verify")
|
|
345
|
+
self.assertEqual(self.state["requests"][-1][2], {"class": "verify"})
|
|
346
|
+
self.client.follow_link(MSG_ID, url="https://acme.test/verify?t=abc")
|
|
347
|
+
self.assertEqual(
|
|
348
|
+
self.state["requests"][-1][2], {"url": "https://acme.test/verify?t=abc"}
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def test_follow_link_no_such_class(self):
|
|
352
|
+
with self.assertRaises(MailFixtureError) as ctx:
|
|
353
|
+
self.client.follow_link(MSG_ID, kind="reset")
|
|
354
|
+
self.assertEqual(ctx.exception.status, 404)
|
|
355
|
+
|
|
356
|
+
def test_follow_sms_link(self):
|
|
357
|
+
result = self.client.follow_sms_link(SMS_ID, index=0)
|
|
358
|
+
self.assertTrue(result.ok)
|
|
359
|
+
self.assertEqual(result.redirects, 0)
|
|
360
|
+
self.assertEqual(self.state["requests"][-1][2], {"index": 0})
|
|
361
|
+
|
|
304
362
|
def test_keys_and_domains(self):
|
|
305
363
|
created = self.client.create_key(label="ci")
|
|
306
364
|
self.assertTrue(created.key.startswith("mfx_"))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|