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.
- {mailsocket-0.2.0 → mailsocket-0.3.1}/PKG-INFO +44 -3
- {mailsocket-0.2.0 → mailsocket-0.3.1}/README.md +43 -2
- {mailsocket-0.2.0 → mailsocket-0.3.1}/mailsocket/__init__.py +1 -1
- {mailsocket-0.2.0 → mailsocket-0.3.1}/mailsocket/client.py +40 -3
- {mailsocket-0.2.0 → mailsocket-0.3.1}/mailsocket.egg-info/PKG-INFO +44 -3
- {mailsocket-0.2.0 → mailsocket-0.3.1}/pyproject.toml +1 -1
- {mailsocket-0.2.0 → mailsocket-0.3.1}/tests/test_client.py +194 -1
- {mailsocket-0.2.0 → mailsocket-0.3.1}/LICENSE +0 -0
- {mailsocket-0.2.0 → mailsocket-0.3.1}/mailsocket/errors.py +0 -0
- {mailsocket-0.2.0 → mailsocket-0.3.1}/mailsocket/py.typed +0 -0
- {mailsocket-0.2.0 → mailsocket-0.3.1}/mailsocket.egg-info/SOURCES.txt +0 -0
- {mailsocket-0.2.0 → mailsocket-0.3.1}/mailsocket.egg-info/dependency_links.txt +0 -0
- {mailsocket-0.2.0 → mailsocket-0.3.1}/mailsocket.egg-info/requires.txt +0 -0
- {mailsocket-0.2.0 → mailsocket-0.3.1}/mailsocket.egg-info/top_level.txt +0 -0
- {mailsocket-0.2.0 → mailsocket-0.3.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mailsocket
|
|
3
|
-
Version: 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"])
|
|
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"])
|
|
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
|
|
@@ -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
|
|
282
|
-
params["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.
|
|
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.
|
|
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"])
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|