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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mailfixture
3
- Version: 0.2.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.2.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.2.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
- self._json(200, {"phone_numbers": [PHONE_NUMBER]})
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