mailsocket 0.3.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.3.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
@@ -162,6 +162,12 @@ invalid id never reaches the network.
162
162
 
163
163
  ## Changelog
164
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.
165
171
  - **0.3.0** — new `Client.send_test_code(inbox_id)` (`POST /inboxes/{id}/test-code`):
166
172
  puts one sample OTP email into your own inbox without going through the
167
173
  mail server, and returns `{"message_id", "sent_at"}`. README samples now
@@ -135,6 +135,12 @@ invalid id never reaches the network.
135
135
 
136
136
  ## Changelog
137
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.
138
144
  - **0.3.0** — new `Client.send_test_code(inbox_id)` (`POST /inboxes/{id}/test-code`):
139
145
  puts one sample OTP email into your own inbox without going through the
140
146
  mail server, and returns `{"message_id", "sent_at"}`. README samples now
@@ -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.3.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
 
@@ -289,6 +291,12 @@ class Client:
289
291
  since,
290
292
  ) -> WaitResult:
291
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
292
300
  while True:
293
301
  remaining = deadline - _monotonic()
294
302
  if remaining <= 0:
@@ -297,8 +305,8 @@ class Client:
297
305
  )
298
306
  server_timeout = max(1.0, min(float(WAIT_TIMEOUT_MAX), remaining))
299
307
  params = {"timeout": server_timeout, "require": require}
300
- if since is not None:
301
- params["since"] = since
308
+ if cursor is not None:
309
+ params["since"] = cursor
302
310
  if min_confidence is not None:
303
311
  params["min_confidence"] = min_confidence
304
312
 
@@ -312,6 +320,7 @@ class Client:
312
320
  if status == 200:
313
321
  return WaitResult(payload["data"])
314
322
  if status == 204:
323
+ cursor = self._next_cursor(since, cursor, headers)
315
324
  continue
316
325
  if status == 429:
317
326
  if not self._retry_wait_on_429:
@@ -326,6 +335,15 @@ class Client:
326
335
  continue
327
336
  self._raise_for_status(status, headers, payload)
328
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
+
329
347
  def _request(self, method: str, path: str, *, params: dict | None = None, json_body=None):
330
348
  status, headers, body = self._request_http(method, path, params=params, json_body=json_body)
331
349
  payload = self._parse_json(body)
@@ -339,7 +357,7 @@ class Client:
339
357
  url += "?" + urllib.parse.urlencode(params, doseq=True)
340
358
  headers = {
341
359
  "Accept": "application/json",
342
- "User-Agent": "mailsocket-python/0.3.0",
360
+ "User-Agent": "mailsocket-python/0.3.1",
343
361
  }
344
362
  headers.update(self._extra_headers)
345
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.3.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
@@ -162,6 +162,12 @@ invalid id never reaches the network.
162
162
 
163
163
  ## Changelog
164
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.
165
171
  - **0.3.0** — new `Client.send_test_code(inbox_id)` (`POST /inboxes/{id}/test-code`):
166
172
  puts one sample OTP email into your own inbox without going through the
167
173
  mail server, and returns `{"message_id", "sent_at"}`. README samples now
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mailsocket"
7
- version = "0.3.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
@@ -582,7 +639,7 @@ def test_metadata_consistency_across_files():
582
639
  from pathlib import Path
583
640
 
584
641
  root = Path(__file__).resolve().parents[1]
585
- expected = "0.3.0"
642
+ expected = "0.3.1"
586
643
 
587
644
  pyproject = tomllib.loads((root / "pyproject.toml").read_text())
588
645
  assert pyproject["project"]["version"] == expected
File without changes
File without changes