mailsocket 0.2.0__tar.gz → 0.3.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: mailsocket
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Official Python client for the mailsocket REST API — one line to wait for an OTP.
5
5
  Author: mailsocket
6
6
  License-Expression: MIT
@@ -31,17 +31,34 @@ The official Python client for the [mailsocket](https://mailsocket.app) v1 REST
31
31
  Ephemeral email inboxes, message retrieval, and — the whole point — **wait for the OTP in one line**.
32
32
 
33
33
  ```python
34
+ import time
35
+
34
36
  from mailsocket import Client
35
37
 
36
38
  client = Client("ms_live_...") # your API key from the dashboard
37
39
  inbox = client.create_inbox(label="signup") # -> {"id": "inbox_...", "address": "..."}
38
40
 
41
+ started = time.time() # take the cutoff BEFORE the trigger
39
42
  # hand inbox["address"] to whatever form sends the code, then:
40
- result = client.wait_for_otp(inbox["id"]) # blocks up to 60s
43
+ result = client.wait_for_otp(inbox["id"], since=started) # blocks up to 60s
41
44
  print(result.otp) # "123456"
42
45
  print(result.confidence) # 0.95
43
46
  ```
44
47
 
48
+ Take `started` just before you trigger the email and pass it as `since`: a
49
+ code that lands before `wait_for_otp` starts is still caught, and an older
50
+ code in a reused inbox is not.
51
+
52
+ No email handy? `client.send_test_code(inbox_id)` drops a sample code into
53
+ the inbox so you can see the whole loop work:
54
+
55
+ ```python
56
+ started = time.time()
57
+ client.send_test_code(inbox["id"]) # -> {"message_id": "msg_...", "sent_at": "..."}
58
+ result = client.wait_for_otp(inbox["id"], since=started)
59
+ print(result.otp) # the sample code
60
+ ```
61
+
45
62
  No polling loop. No regex over the email body. `wait_for_otp` long-polls the
46
63
  server (which already knows how to extract the code) and hands back a
47
64
  deterministic result with a confidence score.
@@ -69,6 +86,7 @@ client = Client(api_key, base_url="https://dash.mailsocket.app/api/v1")
69
86
  | `list_messages(inbox_id, has_otp=None, subject_contains=None, sender=None, ...)` | `Page` — `sender` maps to the API's `from` filter |
70
87
  | `get_latest(inbox_id)` | `dict` — newest message |
71
88
  | `get_message(message_id)` | `dict` — full message |
89
+ | `send_test_code(inbox_id)` | `dict` — `message_id`, `sent_at` of a sample OTP email put straight into the inbox (no mail server; 5 per inbox / 20 per account per hour) |
72
90
 
73
91
  ### The moat
74
92
 
@@ -78,6 +96,14 @@ result = client.wait_for_link("inbox_abc123", timeout=60, since=None)
78
96
  result = client.wait("inbox_abc123", timeout=60, min_confidence=0.0, since=None) # otp OR link
79
97
  ```
80
98
 
99
+ With a cutoff taken before the trigger (the usual case):
100
+
101
+ ```python
102
+ started = time.time()
103
+ # trigger the email here
104
+ result = client.wait_for_otp("inbox_abc123", since=started)
105
+ ```
106
+
81
107
  All three return a `WaitResult` and take keyword-only arguments after
82
108
  `inbox_id`. `timeout`/`min_confidence`/`since` shown above are the defaults —
83
109
  call with no extra arguments for the common case.
@@ -112,11 +138,16 @@ Semantics:
112
138
  `MailsocketError` (base) with subclasses:
113
139
 
114
140
  - `AuthError` — HTTP 401 (bad/missing key).
115
- - `NotFound` — HTTP 404.
141
+ - `NotFound` — HTTP 404 (also an unknown, deleted or foreign inbox in
142
+ `send_test_code`).
116
143
  - `RateLimited` — HTTP 429, carries `.subcode` (`rate_limited`,
117
144
  `too_many_wait_requests`, `wait_capacity`) and `.retry_after`.
118
145
  - `WaitTimeout` — no matching message within the overall deadline.
119
146
 
147
+ `send_test_code` on a disabled inbox raises the base `MailsocketError` with
148
+ `code="inbox_disabled"` and `status=409`. Over its hourly quota it raises
149
+ `RateLimited`; `.retry_after` says how many seconds until it frees up.
150
+
120
151
  ## Develop
121
152
 
122
153
  ```bash
@@ -131,6 +162,10 @@ invalid id never reaches the network.
131
162
 
132
163
  ## Changelog
133
164
 
165
+ - **0.3.0** — new `Client.send_test_code(inbox_id)` (`POST /inboxes/{id}/test-code`):
166
+ puts one sample OTP email into your own inbox without going through the
167
+ mail server, and returns `{"message_id", "sent_at"}`. README samples now
168
+ take `started = time.time()` before the trigger and pass `since=started`.
134
169
  - **0.2.0** — BEHAVIOUR CHANGE: `wait_for_otp`/`wait_for_link`/`wait` now
135
170
  default `since=None`, which omits the param so the server uses the
136
171
  request start time, instead of the old `since=0` ("any message already
@@ -4,17 +4,34 @@ The official Python client for the [mailsocket](https://mailsocket.app) v1 REST
4
4
  Ephemeral email inboxes, message retrieval, and — the whole point — **wait for the OTP in one line**.
5
5
 
6
6
  ```python
7
+ import time
8
+
7
9
  from mailsocket import Client
8
10
 
9
11
  client = Client("ms_live_...") # your API key from the dashboard
10
12
  inbox = client.create_inbox(label="signup") # -> {"id": "inbox_...", "address": "..."}
11
13
 
14
+ started = time.time() # take the cutoff BEFORE the trigger
12
15
  # hand inbox["address"] to whatever form sends the code, then:
13
- result = client.wait_for_otp(inbox["id"]) # blocks up to 60s
16
+ result = client.wait_for_otp(inbox["id"], since=started) # blocks up to 60s
14
17
  print(result.otp) # "123456"
15
18
  print(result.confidence) # 0.95
16
19
  ```
17
20
 
21
+ Take `started` just before you trigger the email and pass it as `since`: a
22
+ code that lands before `wait_for_otp` starts is still caught, and an older
23
+ code in a reused inbox is not.
24
+
25
+ No email handy? `client.send_test_code(inbox_id)` drops a sample code into
26
+ the inbox so you can see the whole loop work:
27
+
28
+ ```python
29
+ started = time.time()
30
+ client.send_test_code(inbox["id"]) # -> {"message_id": "msg_...", "sent_at": "..."}
31
+ result = client.wait_for_otp(inbox["id"], since=started)
32
+ print(result.otp) # the sample code
33
+ ```
34
+
18
35
  No polling loop. No regex over the email body. `wait_for_otp` long-polls the
19
36
  server (which already knows how to extract the code) and hands back a
20
37
  deterministic result with a confidence score.
@@ -42,6 +59,7 @@ client = Client(api_key, base_url="https://dash.mailsocket.app/api/v1")
42
59
  | `list_messages(inbox_id, has_otp=None, subject_contains=None, sender=None, ...)` | `Page` — `sender` maps to the API's `from` filter |
43
60
  | `get_latest(inbox_id)` | `dict` — newest message |
44
61
  | `get_message(message_id)` | `dict` — full message |
62
+ | `send_test_code(inbox_id)` | `dict` — `message_id`, `sent_at` of a sample OTP email put straight into the inbox (no mail server; 5 per inbox / 20 per account per hour) |
45
63
 
46
64
  ### The moat
47
65
 
@@ -51,6 +69,14 @@ result = client.wait_for_link("inbox_abc123", timeout=60, since=None)
51
69
  result = client.wait("inbox_abc123", timeout=60, min_confidence=0.0, since=None) # otp OR link
52
70
  ```
53
71
 
72
+ With a cutoff taken before the trigger (the usual case):
73
+
74
+ ```python
75
+ started = time.time()
76
+ # trigger the email here
77
+ result = client.wait_for_otp("inbox_abc123", since=started)
78
+ ```
79
+
54
80
  All three return a `WaitResult` and take keyword-only arguments after
55
81
  `inbox_id`. `timeout`/`min_confidence`/`since` shown above are the defaults —
56
82
  call with no extra arguments for the common case.
@@ -85,11 +111,16 @@ Semantics:
85
111
  `MailsocketError` (base) with subclasses:
86
112
 
87
113
  - `AuthError` — HTTP 401 (bad/missing key).
88
- - `NotFound` — HTTP 404.
114
+ - `NotFound` — HTTP 404 (also an unknown, deleted or foreign inbox in
115
+ `send_test_code`).
89
116
  - `RateLimited` — HTTP 429, carries `.subcode` (`rate_limited`,
90
117
  `too_many_wait_requests`, `wait_capacity`) and `.retry_after`.
91
118
  - `WaitTimeout` — no matching message within the overall deadline.
92
119
 
120
+ `send_test_code` on a disabled inbox raises the base `MailsocketError` with
121
+ `code="inbox_disabled"` and `status=409`. Over its hourly quota it raises
122
+ `RateLimited`; `.retry_after` says how many seconds until it frees up.
123
+
93
124
  ## Develop
94
125
 
95
126
  ```bash
@@ -104,6 +135,10 @@ invalid id never reaches the network.
104
135
 
105
136
  ## Changelog
106
137
 
138
+ - **0.3.0** — new `Client.send_test_code(inbox_id)` (`POST /inboxes/{id}/test-code`):
139
+ puts one sample OTP email into your own inbox without going through the
140
+ mail server, and returns `{"message_id", "sent_at"}`. README samples now
141
+ take `started = time.time()` before the trigger and pass `since=started`.
107
142
  - **0.2.0** — BEHAVIOUR CHANGE: `wait_for_otp`/`wait_for_link`/`wait` now
108
143
  default `since=None`, which omits the param so the server uses the
109
144
  request start time, instead of the old `since=0` ("any message already
@@ -3,7 +3,7 @@
3
3
  from .client import Client, Page, WaitResult
4
4
  from .errors import AuthError, MailsocketError, NotFound, RateLimited, WaitTimeout
5
5
 
6
- __version__ = "0.2.0"
6
+ __version__ = "0.3.0"
7
7
 
8
8
  __all__ = [
9
9
  "Client",
@@ -201,6 +201,25 @@ class Client:
201
201
  payload = self._request("GET", f"/messages/{_seg(message_id)}")
202
202
  return payload["data"]
203
203
 
204
+ def send_test_code(self, inbox_id: str) -> dict:
205
+ """Put one sample OTP email (a random 6-digit code) into ``inbox_id``.
206
+
207
+ Calls ``POST /inboxes/{id}/test-code`` and returns its ``data`` dict:
208
+ ``{"message_id": "msg_...", "sent_at": "..."}``. The message does NOT
209
+ go through the mail server: it is stored and parsed like real inbound
210
+ mail, so a pending or later wait (with a ``since`` from before this
211
+ call) returns it. Use it to prove the wait loop end to end without
212
+ sending real email.
213
+
214
+ Shares the dashboard "Send a test code" quota (5 per inbox, 20 per
215
+ account per hour). Errors: :class:`NotFound` for an unknown, deleted
216
+ or foreign inbox; :class:`RateLimited` (with ``retry_after``) over the
217
+ quota; :class:`MailsocketError` with ``code="inbox_disabled"``
218
+ (HTTP 409) for a disabled inbox.
219
+ """
220
+ payload = self._request("POST", f"/inboxes/{_seg(inbox_id)}/test-code")
221
+ return payload["data"]
222
+
204
223
  # -- the moat -------------------------------------------------------------
205
224
 
206
225
  def wait_for_otp(
@@ -320,7 +339,7 @@ class Client:
320
339
  url += "?" + urllib.parse.urlencode(params, doseq=True)
321
340
  headers = {
322
341
  "Accept": "application/json",
323
- "User-Agent": "mailsocket-python/0.2.0",
342
+ "User-Agent": "mailsocket-python/0.3.0",
324
343
  }
325
344
  headers.update(self._extra_headers)
326
345
  # Set last so nothing in extra_headers can ever replace it.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mailsocket
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Official Python client for the mailsocket REST API — one line to wait for an OTP.
5
5
  Author: mailsocket
6
6
  License-Expression: MIT
@@ -31,17 +31,34 @@ The official Python client for the [mailsocket](https://mailsocket.app) v1 REST
31
31
  Ephemeral email inboxes, message retrieval, and — the whole point — **wait for the OTP in one line**.
32
32
 
33
33
  ```python
34
+ import time
35
+
34
36
  from mailsocket import Client
35
37
 
36
38
  client = Client("ms_live_...") # your API key from the dashboard
37
39
  inbox = client.create_inbox(label="signup") # -> {"id": "inbox_...", "address": "..."}
38
40
 
41
+ started = time.time() # take the cutoff BEFORE the trigger
39
42
  # hand inbox["address"] to whatever form sends the code, then:
40
- result = client.wait_for_otp(inbox["id"]) # blocks up to 60s
43
+ result = client.wait_for_otp(inbox["id"], since=started) # blocks up to 60s
41
44
  print(result.otp) # "123456"
42
45
  print(result.confidence) # 0.95
43
46
  ```
44
47
 
48
+ Take `started` just before you trigger the email and pass it as `since`: a
49
+ code that lands before `wait_for_otp` starts is still caught, and an older
50
+ code in a reused inbox is not.
51
+
52
+ No email handy? `client.send_test_code(inbox_id)` drops a sample code into
53
+ the inbox so you can see the whole loop work:
54
+
55
+ ```python
56
+ started = time.time()
57
+ client.send_test_code(inbox["id"]) # -> {"message_id": "msg_...", "sent_at": "..."}
58
+ result = client.wait_for_otp(inbox["id"], since=started)
59
+ print(result.otp) # the sample code
60
+ ```
61
+
45
62
  No polling loop. No regex over the email body. `wait_for_otp` long-polls the
46
63
  server (which already knows how to extract the code) and hands back a
47
64
  deterministic result with a confidence score.
@@ -69,6 +86,7 @@ client = Client(api_key, base_url="https://dash.mailsocket.app/api/v1")
69
86
  | `list_messages(inbox_id, has_otp=None, subject_contains=None, sender=None, ...)` | `Page` — `sender` maps to the API's `from` filter |
70
87
  | `get_latest(inbox_id)` | `dict` — newest message |
71
88
  | `get_message(message_id)` | `dict` — full message |
89
+ | `send_test_code(inbox_id)` | `dict` — `message_id`, `sent_at` of a sample OTP email put straight into the inbox (no mail server; 5 per inbox / 20 per account per hour) |
72
90
 
73
91
  ### The moat
74
92
 
@@ -78,6 +96,14 @@ result = client.wait_for_link("inbox_abc123", timeout=60, since=None)
78
96
  result = client.wait("inbox_abc123", timeout=60, min_confidence=0.0, since=None) # otp OR link
79
97
  ```
80
98
 
99
+ With a cutoff taken before the trigger (the usual case):
100
+
101
+ ```python
102
+ started = time.time()
103
+ # trigger the email here
104
+ result = client.wait_for_otp("inbox_abc123", since=started)
105
+ ```
106
+
81
107
  All three return a `WaitResult` and take keyword-only arguments after
82
108
  `inbox_id`. `timeout`/`min_confidence`/`since` shown above are the defaults —
83
109
  call with no extra arguments for the common case.
@@ -112,11 +138,16 @@ Semantics:
112
138
  `MailsocketError` (base) with subclasses:
113
139
 
114
140
  - `AuthError` — HTTP 401 (bad/missing key).
115
- - `NotFound` — HTTP 404.
141
+ - `NotFound` — HTTP 404 (also an unknown, deleted or foreign inbox in
142
+ `send_test_code`).
116
143
  - `RateLimited` — HTTP 429, carries `.subcode` (`rate_limited`,
117
144
  `too_many_wait_requests`, `wait_capacity`) and `.retry_after`.
118
145
  - `WaitTimeout` — no matching message within the overall deadline.
119
146
 
147
+ `send_test_code` on a disabled inbox raises the base `MailsocketError` with
148
+ `code="inbox_disabled"` and `status=409`. Over its hourly quota it raises
149
+ `RateLimited`; `.retry_after` says how many seconds until it frees up.
150
+
120
151
  ## Develop
121
152
 
122
153
  ```bash
@@ -131,6 +162,10 @@ invalid id never reaches the network.
131
162
 
132
163
  ## Changelog
133
164
 
165
+ - **0.3.0** — new `Client.send_test_code(inbox_id)` (`POST /inboxes/{id}/test-code`):
166
+ puts one sample OTP email into your own inbox without going through the
167
+ mail server, and returns `{"message_id", "sent_at"}`. README samples now
168
+ take `started = time.time()` before the trigger and pass `since=started`.
134
169
  - **0.2.0** — BEHAVIOUR CHANGE: `wait_for_otp`/`wait_for_link`/`wait` now
135
170
  default `since=None`, which omits the param so the server uses the
136
171
  request start time, instead of the old `since=0` ("any message already
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mailsocket"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Official Python client for the mailsocket REST API — one line to wait for an OTP."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -557,6 +557,7 @@ def test_invalid_id_rejected_before_any_request_hits_real_server(raw):
557
557
  lambda: client.wait_for_otp(raw, timeout=5),
558
558
  lambda: client.wait_for_link(raw, timeout=5),
559
559
  lambda: client.wait(raw, timeout=5),
560
+ lambda: client.send_test_code(raw),
560
561
  ]
561
562
  for call in calls:
562
563
  with pytest.raises(MailsocketError) as exc_info:
@@ -581,7 +582,7 @@ def test_metadata_consistency_across_files():
581
582
  from pathlib import Path
582
583
 
583
584
  root = Path(__file__).resolve().parents[1]
584
- expected = "0.2.0"
585
+ expected = "0.3.0"
585
586
 
586
587
  pyproject = tomllib.loads((root / "pyproject.toml").read_text())
587
588
  assert pyproject["project"]["version"] == expected
@@ -657,3 +658,138 @@ def test_no_extra_headers_is_backwards_compatible(monkeypatch):
657
658
  sent = capture.headers[0]
658
659
  assert set(sent) >= {"authorization", "accept", "user-agent"}
659
660
  assert "x-forwarded-proto" not in sent
661
+
662
+
663
+ # -- send_test_code (POST /inboxes/{id}/test-code) ----------------------------
664
+
665
+ TEST_CODE_DATA = {"message_id": "msg_test1", "sent_at": "2026-10-08T12:00:00Z"}
666
+
667
+
668
+ def test_send_test_code_posts_and_returns_data(monkeypatch):
669
+ client, transport = make_client([(201, {}, {"data": TEST_CODE_DATA})])
670
+ _monkeypatch_transport(monkeypatch, client, transport)
671
+
672
+ data = client.send_test_code("inbox_abc")
673
+
674
+ assert data == TEST_CODE_DATA
675
+ assert transport.calls == [("POST", "https://example.test/api/v1/inboxes/inbox_abc/test-code")]
676
+
677
+
678
+ def test_send_test_code_sends_no_body(monkeypatch):
679
+ seen = []
680
+
681
+ def capture(request, timeout=None):
682
+ seen.append((request.get_method(), request.data, dict(request.header_items())))
683
+
684
+ class _Resp:
685
+ headers = {}
686
+
687
+ def getcode(self):
688
+ return 201
689
+
690
+ def read(self):
691
+ return json.dumps({"data": TEST_CODE_DATA}).encode()
692
+
693
+ def __enter__(self):
694
+ return self
695
+
696
+ def __exit__(self, *exc_info):
697
+ return False
698
+
699
+ return _Resp()
700
+
701
+ monkeypatch.setattr("urllib.request.urlopen", capture)
702
+ Client("ms_live_test1234567890", base_url="https://example.test/api/v1").send_test_code("inbox_abc")
703
+ method, body, headers = seen[0]
704
+ assert method == "POST"
705
+ assert body is None
706
+ assert headers["Authorization"] == "Bearer ms_live_test1234567890"
707
+
708
+
709
+ def test_send_test_code_encodes_id_as_one_segment(monkeypatch):
710
+ client, transport = make_client([(201, {}, {"data": TEST_CODE_DATA})])
711
+ _monkeypatch_transport(monkeypatch, client, transport)
712
+
713
+ client.send_test_code("a/b?c")
714
+
715
+ assert transport.calls[0][1] == "https://example.test/api/v1/inboxes/a%2Fb%3Fc/test-code"
716
+
717
+
718
+ def test_send_test_code_404_raises_not_found(monkeypatch):
719
+ responses = [(404, {}, {"error": {"code": "not_found", "message": "Resource not found.", "fields": {}}})]
720
+ client, transport = make_client(responses)
721
+ _monkeypatch_transport(monkeypatch, client, transport)
722
+
723
+ with pytest.raises(NotFound) as exc_info:
724
+ client.send_test_code("inbox_foreign")
725
+ assert exc_info.value.status == 404
726
+
727
+
728
+ def test_send_test_code_429_raises_rate_limited_with_retry_after(monkeypatch):
729
+ slept = []
730
+ monkeypatch.setattr(mailsocket.client, "_sleep", lambda s: slept.append(s))
731
+ responses = [
732
+ (
733
+ 429,
734
+ {"Retry-After": "1800"},
735
+ {"error": {"code": "rate_limited", "message": "Too many test codes. Try again at 13:00 UTC.", "fields": {}}},
736
+ )
737
+ ]
738
+ client, transport = make_client(responses)
739
+ _monkeypatch_transport(monkeypatch, client, transport)
740
+
741
+ with pytest.raises(RateLimited) as exc_info:
742
+ client.send_test_code("inbox_abc")
743
+ err = exc_info.value
744
+ assert err.retry_after == 1800.0
745
+ assert err.subcode == "rate_limited"
746
+ assert "Try again at" in str(err)
747
+ assert len(transport.calls) == 1 # never retried
748
+ assert slept == []
749
+
750
+
751
+ def test_send_test_code_disabled_inbox_raises_mailsocket_error(monkeypatch):
752
+ responses = [
753
+ (
754
+ 409,
755
+ {},
756
+ {"error": {"code": "inbox_disabled", "message": "This inbox is disabled. Enable it to receive a test code.", "fields": {}}},
757
+ )
758
+ ]
759
+ client, transport = make_client(responses)
760
+ _monkeypatch_transport(monkeypatch, client, transport)
761
+
762
+ with pytest.raises(MailsocketError) as exc_info:
763
+ client.send_test_code("inbox_abc")
764
+ err = exc_info.value
765
+ assert type(err) is MailsocketError
766
+ assert err.code == "inbox_disabled"
767
+ assert err.status == 409
768
+
769
+
770
+ def test_send_test_code_401_raises_auth_error(monkeypatch):
771
+ responses = [(401, {}, {"error": {"code": "authentication_required", "message": "Auth required.", "fields": {}}})]
772
+ client, transport = make_client(responses)
773
+ _monkeypatch_transport(monkeypatch, client, transport)
774
+
775
+ with pytest.raises(AuthError):
776
+ client.send_test_code("inbox_abc")
777
+
778
+
779
+ def test_readme_wait_samples_take_a_cutoff_before_the_trigger():
780
+ """Every runnable wait sample in the README passes since=started, with
781
+ started taken before the trigger (ux4 SDK follow-up)."""
782
+ from pathlib import Path
783
+
784
+ readme = (Path(__file__).resolve().parents[1] / "README.md").read_text()
785
+ blocks = readme.split("```python")[1:]
786
+ samples = [b.split("```")[0] for b in blocks]
787
+ waits = [s for s in samples if "wait_for_otp(" in s and "since=None" not in s]
788
+ assert waits, "no wait sample found"
789
+ for sample in waits:
790
+ assert "started = time.time()" in sample
791
+ assert sample.index("started = time.time()") < sample.index("wait_for_otp(")
792
+ for line in sample.splitlines():
793
+ if "wait_for_otp(" in line or "wait_for_link(" in line:
794
+ assert "since=" in line, line
795
+ assert "client.send_test_code(inbox" in readme
File without changes
File without changes