mailsocket 0.2.0__tar.gz → 0.3.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: mailsocket
3
- Version: 0.2.0
3
+ Version: 0.3.1
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,16 @@ invalid id never reaches the network.
131
162
 
132
163
  ## Changelog
133
164
 
165
+ - **0.3.1** — the `wait*` helpers no longer miss a message that lands between
166
+ two long-poll calls. When you pass no `since`, the first call still omits it
167
+ (the server uses the request start), and every following call sends the
168
+ cutoff the server reported in the `Mailsocket-Wait-Since` response header of
169
+ the previous call. With an older server that sends no such header, behaviour
170
+ is unchanged. An explicit `since` is used as before on every call.
171
+ - **0.3.0** — new `Client.send_test_code(inbox_id)` (`POST /inboxes/{id}/test-code`):
172
+ puts one sample OTP email into your own inbox without going through the
173
+ mail server, and returns `{"message_id", "sent_at"}`. README samples now
174
+ take `started = time.time()` before the trigger and pass `since=started`.
134
175
  - **0.2.0** — BEHAVIOUR CHANGE: `wait_for_otp`/`wait_for_link`/`wait` now
135
176
  default `since=None`, which omits the param so the server uses the
136
177
  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,16 @@ invalid id never reaches the network.
104
135
 
105
136
  ## Changelog
106
137
 
138
+ - **0.3.1** — the `wait*` helpers no longer miss a message that lands between
139
+ two long-poll calls. When you pass no `since`, the first call still omits it
140
+ (the server uses the request start), and every following call sends the
141
+ cutoff the server reported in the `Mailsocket-Wait-Since` response header of
142
+ the previous call. With an older server that sends no such header, behaviour
143
+ is unchanged. An explicit `since` is used as before on every call.
144
+ - **0.3.0** — new `Client.send_test_code(inbox_id)` (`POST /inboxes/{id}/test-code`):
145
+ puts one sample OTP email into your own inbox without going through the
146
+ mail server, and returns `{"message_id", "sent_at"}`. README samples now
147
+ take `started = time.time()` before the trigger and pass `since=started`.
107
148
  - **0.2.0** — BEHAVIOUR CHANGE: `wait_for_otp`/`wait_for_link`/`wait` now
108
149
  default `since=None`, which omits the param so the server uses the
109
150
  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.1"
7
7
 
8
8
  __all__ = [
9
9
  "Client",
@@ -18,6 +18,8 @@ DEFAULT_BASE_URL = "https://dash.mailsocket.app/api/v1"
18
18
  WAIT_TIMEOUT_MAX = 25 # seconds — the server clamps the per-call wait to [1, 25]
19
19
  WAIT_SOCKET_BUFFER = 10 # seconds of client-side slack above the server block
20
20
  DEFAULT_RETRY_AFTER = 1.0 # seconds, when a 429 carries no Retry-After header
21
+ # Response header with the cutoff a wait call actually used (server >= 2026-10).
22
+ WAIT_SINCE_HEADER = "mailsocket-wait-since"
21
23
 
22
24
  __all__ = ["Client", "Page", "WaitResult"]
23
25
 
@@ -201,6 +203,25 @@ class Client:
201
203
  payload = self._request("GET", f"/messages/{_seg(message_id)}")
202
204
  return payload["data"]
203
205
 
206
+ def send_test_code(self, inbox_id: str) -> dict:
207
+ """Put one sample OTP email (a random 6-digit code) into ``inbox_id``.
208
+
209
+ Calls ``POST /inboxes/{id}/test-code`` and returns its ``data`` dict:
210
+ ``{"message_id": "msg_...", "sent_at": "..."}``. The message does NOT
211
+ go through the mail server: it is stored and parsed like real inbound
212
+ mail, so a pending or later wait (with a ``since`` from before this
213
+ call) returns it. Use it to prove the wait loop end to end without
214
+ sending real email.
215
+
216
+ Shares the dashboard "Send a test code" quota (5 per inbox, 20 per
217
+ account per hour). Errors: :class:`NotFound` for an unknown, deleted
218
+ or foreign inbox; :class:`RateLimited` (with ``retry_after``) over the
219
+ quota; :class:`MailsocketError` with ``code="inbox_disabled"``
220
+ (HTTP 409) for a disabled inbox.
221
+ """
222
+ payload = self._request("POST", f"/inboxes/{_seg(inbox_id)}/test-code")
223
+ return payload["data"]
224
+
204
225
  # -- the moat -------------------------------------------------------------
205
226
 
206
227
  def wait_for_otp(
@@ -270,6 +291,12 @@ class Client:
270
291
  since,
271
292
  ) -> WaitResult:
272
293
  deadline = _monotonic() + float(timeout)
294
+ # No caller ``since``: the first call omits it (server default = the
295
+ # request start), and every later call sends back the cutoff the
296
+ # previous response reported, so a message that lands between two
297
+ # calls is still matched. An older server sends no header; then each
298
+ # call keeps omitting ``since`` exactly as before.
299
+ cursor = since
273
300
  while True:
274
301
  remaining = deadline - _monotonic()
275
302
  if remaining <= 0:
@@ -278,8 +305,8 @@ class Client:
278
305
  )
279
306
  server_timeout = max(1.0, min(float(WAIT_TIMEOUT_MAX), remaining))
280
307
  params = {"timeout": server_timeout, "require": require}
281
- if since is not None:
282
- params["since"] = since
308
+ if cursor is not None:
309
+ params["since"] = cursor
283
310
  if min_confidence is not None:
284
311
  params["min_confidence"] = min_confidence
285
312
 
@@ -293,6 +320,7 @@ class Client:
293
320
  if status == 200:
294
321
  return WaitResult(payload["data"])
295
322
  if status == 204:
323
+ cursor = self._next_cursor(since, cursor, headers)
296
324
  continue
297
325
  if status == 429:
298
326
  if not self._retry_wait_on_429:
@@ -307,6 +335,15 @@ class Client:
307
335
  continue
308
336
  self._raise_for_status(status, headers, payload)
309
337
 
338
+ @staticmethod
339
+ def _next_cursor(since, cursor, headers: dict):
340
+ """The ``since`` for the next wait call: the caller's own value when
341
+ given, else the server-reported cutoff of this call, else unchanged
342
+ (an older server sends no header)."""
343
+ if since is not None:
344
+ return cursor
345
+ return headers.get(WAIT_SINCE_HEADER) or cursor
346
+
310
347
  def _request(self, method: str, path: str, *, params: dict | None = None, json_body=None):
311
348
  status, headers, body = self._request_http(method, path, params=params, json_body=json_body)
312
349
  payload = self._parse_json(body)
@@ -320,7 +357,7 @@ class Client:
320
357
  url += "?" + urllib.parse.urlencode(params, doseq=True)
321
358
  headers = {
322
359
  "Accept": "application/json",
323
- "User-Agent": "mailsocket-python/0.2.0",
360
+ "User-Agent": "mailsocket-python/0.3.1",
324
361
  }
325
362
  headers.update(self._extra_headers)
326
363
  # 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.1
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,16 @@ invalid id never reaches the network.
131
162
 
132
163
  ## Changelog
133
164
 
165
+ - **0.3.1** — the `wait*` helpers no longer miss a message that lands between
166
+ two long-poll calls. When you pass no `since`, the first call still omits it
167
+ (the server uses the request start), and every following call sends the
168
+ cutoff the server reported in the `Mailsocket-Wait-Since` response header of
169
+ the previous call. With an older server that sends no such header, behaviour
170
+ is unchanged. An explicit `since` is used as before on every call.
171
+ - **0.3.0** — new `Client.send_test_code(inbox_id)` (`POST /inboxes/{id}/test-code`):
172
+ puts one sample OTP email into your own inbox without going through the
173
+ mail server, and returns `{"message_id", "sent_at"}`. README samples now
174
+ take `started = time.time()` before the trigger and pass `since=started`.
134
175
  - **0.2.0** — BEHAVIOUR CHANGE: `wait_for_otp`/`wait_for_link`/`wait` now
135
176
  default `since=None`, which omits the param so the server uses the
136
177
  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.1"
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"
@@ -193,6 +193,63 @@ def test_wait_keeps_polling_through_204s_then_returns(monkeypatch):
193
193
  assert "require=otp" in url
194
194
 
195
195
 
196
+ def _since_of(url):
197
+ query = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)
198
+ return query.get("since", [None])[0]
199
+
200
+
201
+ CURSOR_1 = "2026-10-12T03:04:05.123456Z"
202
+ CURSOR_2 = "2026-10-12T03:04:30.654321Z"
203
+ EPOCH = "1970-01-01T00:00:00.000000Z"
204
+ H = "Mailsocket-Wait-Since"
205
+
206
+
207
+ @pytest.mark.parametrize(
208
+ "method, kwargs, headers, expected",
209
+ [
210
+ # 0.3.1 gap fix: no caller since -> call 1 omits it, call N sends the
211
+ # header of call N-1, so a message landing between calls is matched.
212
+ ("wait_for_otp", {}, [{H: CURSOR_1}, {H: CURSOR_1}, {}], [None, CURSOR_1, CURSOR_1]),
213
+ ("wait_for_link", {}, [{H: CURSOR_1}, {}], [None, CURSOR_1]),
214
+ ("wait", {}, [{H: CURSOR_1}, {}], [None, CURSOR_1]),
215
+ ("wait_for_otp", {}, [{H: CURSOR_1}, {H: CURSOR_2}, {}], [None, CURSOR_1, CURSOR_2]),
216
+ ("wait_for_otp", {}, [{H.upper(): CURSOR_1}, {}], [None, CURSOR_1]),
217
+ # Old server (no header) or an empty header: unchanged 0.3.0 behaviour.
218
+ ("wait_for_otp", {}, [{}, {}, {}], [None, None, None]),
219
+ ("wait_for_otp", {}, [{H: ""}, {}], [None, None]),
220
+ # An explicit since is never replaced by the header.
221
+ ("wait_for_otp", {"since": "msg_anchor"}, [{H: CURSOR_1}, {H: CURSOR_1}, {}], ["msg_anchor"] * 3),
222
+ ("wait_for_otp", {"since": 0}, [{H: EPOCH}, {}], ["0", "0"]),
223
+ ],
224
+ )
225
+ def test_wait_loop_cursor(monkeypatch, method, kwargs, headers, expected):
226
+ final = LINK_MESSAGE if method == "wait_for_link" else OTP_MESSAGE
227
+ responses = [(204, h, None) for h in headers[:-1]] + [(200, headers[-1], {"data": final})]
228
+ client, transport = make_client(responses)
229
+ _monkeypatch_transport(monkeypatch, client, transport)
230
+
231
+ getattr(client, method)("inbox_abc", timeout=60, **kwargs)
232
+
233
+ assert [_since_of(url) for _, url in transport.calls] == expected
234
+
235
+
236
+ def test_wait_loop_keeps_cursor_across_a_429_retry_and_not_between_waits(monkeypatch):
237
+ responses = [
238
+ (204, {H: CURSOR_1}, None),
239
+ (429, {"Retry-After": "0"}, {"error": {"code": "wait_capacity", "message": "busy"}}),
240
+ (200, {}, {"data": OTP_MESSAGE}),
241
+ (200, {}, {"data": OTP_MESSAGE}),
242
+ ]
243
+ client, transport = make_client(responses)
244
+ _monkeypatch_transport(monkeypatch, client, transport)
245
+ monkeypatch.setattr(mailsocket.client, "_sleep", lambda s: None)
246
+
247
+ client.wait_for_otp("inbox_abc", timeout=60)
248
+ client.wait_for_otp("inbox_abc", timeout=60) # a new wait starts fresh
249
+
250
+ assert [_since_of(url) for _, url in transport.calls] == [None, CURSOR_1, CURSOR_1, None]
251
+
252
+
196
253
  def test_wait_respects_overall_timeout_raises_wait_timeout(monkeypatch):
197
254
  # A 204 means "the server blocked for `timeout` seconds and found nothing".
198
255
  # Model that with a controllable clock: each 204 advances time by the
@@ -557,6 +614,7 @@ def test_invalid_id_rejected_before_any_request_hits_real_server(raw):
557
614
  lambda: client.wait_for_otp(raw, timeout=5),
558
615
  lambda: client.wait_for_link(raw, timeout=5),
559
616
  lambda: client.wait(raw, timeout=5),
617
+ lambda: client.send_test_code(raw),
560
618
  ]
561
619
  for call in calls:
562
620
  with pytest.raises(MailsocketError) as exc_info:
@@ -581,7 +639,7 @@ def test_metadata_consistency_across_files():
581
639
  from pathlib import Path
582
640
 
583
641
  root = Path(__file__).resolve().parents[1]
584
- expected = "0.2.0"
642
+ expected = "0.3.1"
585
643
 
586
644
  pyproject = tomllib.loads((root / "pyproject.toml").read_text())
587
645
  assert pyproject["project"]["version"] == expected
@@ -657,3 +715,138 @@ def test_no_extra_headers_is_backwards_compatible(monkeypatch):
657
715
  sent = capture.headers[0]
658
716
  assert set(sent) >= {"authorization", "accept", "user-agent"}
659
717
  assert "x-forwarded-proto" not in sent
718
+
719
+
720
+ # -- send_test_code (POST /inboxes/{id}/test-code) ----------------------------
721
+
722
+ TEST_CODE_DATA = {"message_id": "msg_test1", "sent_at": "2026-10-08T12:00:00Z"}
723
+
724
+
725
+ def test_send_test_code_posts_and_returns_data(monkeypatch):
726
+ client, transport = make_client([(201, {}, {"data": TEST_CODE_DATA})])
727
+ _monkeypatch_transport(monkeypatch, client, transport)
728
+
729
+ data = client.send_test_code("inbox_abc")
730
+
731
+ assert data == TEST_CODE_DATA
732
+ assert transport.calls == [("POST", "https://example.test/api/v1/inboxes/inbox_abc/test-code")]
733
+
734
+
735
+ def test_send_test_code_sends_no_body(monkeypatch):
736
+ seen = []
737
+
738
+ def capture(request, timeout=None):
739
+ seen.append((request.get_method(), request.data, dict(request.header_items())))
740
+
741
+ class _Resp:
742
+ headers = {}
743
+
744
+ def getcode(self):
745
+ return 201
746
+
747
+ def read(self):
748
+ return json.dumps({"data": TEST_CODE_DATA}).encode()
749
+
750
+ def __enter__(self):
751
+ return self
752
+
753
+ def __exit__(self, *exc_info):
754
+ return False
755
+
756
+ return _Resp()
757
+
758
+ monkeypatch.setattr("urllib.request.urlopen", capture)
759
+ Client("ms_live_test1234567890", base_url="https://example.test/api/v1").send_test_code("inbox_abc")
760
+ method, body, headers = seen[0]
761
+ assert method == "POST"
762
+ assert body is None
763
+ assert headers["Authorization"] == "Bearer ms_live_test1234567890"
764
+
765
+
766
+ def test_send_test_code_encodes_id_as_one_segment(monkeypatch):
767
+ client, transport = make_client([(201, {}, {"data": TEST_CODE_DATA})])
768
+ _monkeypatch_transport(monkeypatch, client, transport)
769
+
770
+ client.send_test_code("a/b?c")
771
+
772
+ assert transport.calls[0][1] == "https://example.test/api/v1/inboxes/a%2Fb%3Fc/test-code"
773
+
774
+
775
+ def test_send_test_code_404_raises_not_found(monkeypatch):
776
+ responses = [(404, {}, {"error": {"code": "not_found", "message": "Resource not found.", "fields": {}}})]
777
+ client, transport = make_client(responses)
778
+ _monkeypatch_transport(monkeypatch, client, transport)
779
+
780
+ with pytest.raises(NotFound) as exc_info:
781
+ client.send_test_code("inbox_foreign")
782
+ assert exc_info.value.status == 404
783
+
784
+
785
+ def test_send_test_code_429_raises_rate_limited_with_retry_after(monkeypatch):
786
+ slept = []
787
+ monkeypatch.setattr(mailsocket.client, "_sleep", lambda s: slept.append(s))
788
+ responses = [
789
+ (
790
+ 429,
791
+ {"Retry-After": "1800"},
792
+ {"error": {"code": "rate_limited", "message": "Too many test codes. Try again at 13:00 UTC.", "fields": {}}},
793
+ )
794
+ ]
795
+ client, transport = make_client(responses)
796
+ _monkeypatch_transport(monkeypatch, client, transport)
797
+
798
+ with pytest.raises(RateLimited) as exc_info:
799
+ client.send_test_code("inbox_abc")
800
+ err = exc_info.value
801
+ assert err.retry_after == 1800.0
802
+ assert err.subcode == "rate_limited"
803
+ assert "Try again at" in str(err)
804
+ assert len(transport.calls) == 1 # never retried
805
+ assert slept == []
806
+
807
+
808
+ def test_send_test_code_disabled_inbox_raises_mailsocket_error(monkeypatch):
809
+ responses = [
810
+ (
811
+ 409,
812
+ {},
813
+ {"error": {"code": "inbox_disabled", "message": "This inbox is disabled. Enable it to receive a test code.", "fields": {}}},
814
+ )
815
+ ]
816
+ client, transport = make_client(responses)
817
+ _monkeypatch_transport(monkeypatch, client, transport)
818
+
819
+ with pytest.raises(MailsocketError) as exc_info:
820
+ client.send_test_code("inbox_abc")
821
+ err = exc_info.value
822
+ assert type(err) is MailsocketError
823
+ assert err.code == "inbox_disabled"
824
+ assert err.status == 409
825
+
826
+
827
+ def test_send_test_code_401_raises_auth_error(monkeypatch):
828
+ responses = [(401, {}, {"error": {"code": "authentication_required", "message": "Auth required.", "fields": {}}})]
829
+ client, transport = make_client(responses)
830
+ _monkeypatch_transport(monkeypatch, client, transport)
831
+
832
+ with pytest.raises(AuthError):
833
+ client.send_test_code("inbox_abc")
834
+
835
+
836
+ def test_readme_wait_samples_take_a_cutoff_before_the_trigger():
837
+ """Every runnable wait sample in the README passes since=started, with
838
+ started taken before the trigger (ux4 SDK follow-up)."""
839
+ from pathlib import Path
840
+
841
+ readme = (Path(__file__).resolve().parents[1] / "README.md").read_text()
842
+ blocks = readme.split("```python")[1:]
843
+ samples = [b.split("```")[0] for b in blocks]
844
+ waits = [s for s in samples if "wait_for_otp(" in s and "since=None" not in s]
845
+ assert waits, "no wait sample found"
846
+ for sample in waits:
847
+ assert "started = time.time()" in sample
848
+ assert sample.index("started = time.time()") < sample.index("wait_for_otp(")
849
+ for line in sample.splitlines():
850
+ if "wait_for_otp(" in line or "wait_for_link(" in line:
851
+ assert "since=" in line, line
852
+ assert "client.send_test_code(inbox" in readme
File without changes
File without changes