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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mailfixture
3
- Version: 0.2.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.2.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.2.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