mailsocket 0.1.3__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.1.3
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
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3.10
17
17
  Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Topic :: Communications :: Email
20
+ Classifier: Typing :: Typed
20
21
  Requires-Python: >=3.9
21
22
  Description-Content-Type: text/markdown
22
23
  License-File: LICENSE
@@ -30,17 +31,34 @@ The official Python client for the [mailsocket](https://mailsocket.app) v1 REST
30
31
  Ephemeral email inboxes, message retrieval, and — the whole point — **wait for the OTP in one line**.
31
32
 
32
33
  ```python
34
+ import time
35
+
33
36
  from mailsocket import Client
34
37
 
35
38
  client = Client("ms_live_...") # your API key from the dashboard
36
39
  inbox = client.create_inbox(label="signup") # -> {"id": "inbox_...", "address": "..."}
37
40
 
41
+ started = time.time() # take the cutoff BEFORE the trigger
38
42
  # hand inbox["address"] to whatever form sends the code, then:
39
- 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
40
44
  print(result.otp) # "123456"
41
45
  print(result.confidence) # 0.95
42
46
  ```
43
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
+
44
62
  No polling loop. No regex over the email body. `wait_for_otp` long-polls the
45
63
  server (which already knows how to extract the code) and hands back a
46
64
  deterministic result with a confidence score.
@@ -68,13 +86,22 @@ client = Client(api_key, base_url="https://dash.mailsocket.app/api/v1")
68
86
  | `list_messages(inbox_id, has_otp=None, subject_contains=None, sender=None, ...)` | `Page` — `sender` maps to the API's `from` filter |
69
87
  | `get_latest(inbox_id)` | `dict` — newest message |
70
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) |
71
90
 
72
91
  ### The moat
73
92
 
74
93
  ```python
75
- result = client.wait_for_otp("inbox_abc123", timeout=60, min_confidence=0.0, since=0)
76
- result = client.wait_for_link("inbox_abc123", timeout=60, since=0)
77
- result = client.wait("inbox_abc123", timeout=60, min_confidence=0.0, since=0) # otp OR link
94
+ result = client.wait_for_otp("inbox_abc123", timeout=60, min_confidence=0.0, since=None)
95
+ result = client.wait_for_link("inbox_abc123", timeout=60, since=None)
96
+ result = client.wait("inbox_abc123", timeout=60, min_confidence=0.0, since=None) # otp OR link
97
+ ```
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)
78
105
  ```
79
106
 
80
107
  All three return a `WaitResult` and take keyword-only arguments after
@@ -83,9 +110,16 @@ call with no extra arguments for the common case.
83
110
 
84
111
  | Method | Keyword args (all optional, keyword-only) | Returns |
85
112
  | --- | --- | --- |
86
- | `wait_for_otp(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=0` | `WaitResult` |
87
- | `wait_for_link(inbox_id, ...)` | `timeout=60`, `since=0` | `WaitResult` |
88
- | `wait(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=0` | `WaitResult` (otp OR link) |
113
+ | `wait_for_otp(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=None` | `WaitResult` |
114
+ | `wait_for_link(inbox_id, ...)` | `timeout=60`, `since=None` | `WaitResult` |
115
+ | `wait(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=None` | `WaitResult` (otp OR link) |
116
+
117
+ `since=None` (the default) omits the param, so the server uses its own
118
+ default: the request start time — a code that arrives during this call is
119
+ caught, and a stale code already in a reused inbox is not. Pass `since=0` to
120
+ restore the old "any message already in the inbox" behaviour (useful right
121
+ after `create_inbox`, where there's nothing stale to match), or a message id
122
+ / timestamp to continue after a specific point.
89
123
 
90
124
  `WaitResult` exposes `.otp`, `.confidence`, `.magic_link` (and `.link` as an
91
125
  alias), plus the full `.message` dict. `str(result)` is the OTP (or the link).
@@ -104,11 +138,16 @@ Semantics:
104
138
  `MailsocketError` (base) with subclasses:
105
139
 
106
140
  - `AuthError` — HTTP 401 (bad/missing key).
107
- - `NotFound` — HTTP 404.
141
+ - `NotFound` — HTTP 404 (also an unknown, deleted or foreign inbox in
142
+ `send_test_code`).
108
143
  - `RateLimited` — HTTP 429, carries `.subcode` (`rate_limited`,
109
144
  `too_many_wait_requests`, `wait_capacity`) and `.retry_after`.
110
145
  - `WaitTimeout` — no matching message within the overall deadline.
111
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
+
112
151
  ## Develop
113
152
 
114
153
  ```bash
@@ -123,4 +162,13 @@ invalid id never reaches the network.
123
162
 
124
163
  ## Changelog
125
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`.
169
+ - **0.2.0** — BEHAVIOUR CHANGE: `wait_for_otp`/`wait_for_link`/`wait` now
170
+ default `since=None`, which omits the param so the server uses the
171
+ request start time, instead of the old `since=0` ("any message already
172
+ in the inbox"). The old default silently returned a STALE OTP/link from a
173
+ reused inbox. Pass `since=0` explicitly to keep the old behaviour.
126
174
  - **0.1.3** — `_seg` now rejects an empty id or a bare `.`/`..` id with a `MailsocketError` (`code="invalid_id"`) before any request is made, matching the TypeScript SDK's contract.
@@ -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,13 +59,22 @@ 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
 
48
66
  ```python
49
- result = client.wait_for_otp("inbox_abc123", timeout=60, min_confidence=0.0, since=0)
50
- result = client.wait_for_link("inbox_abc123", timeout=60, since=0)
51
- result = client.wait("inbox_abc123", timeout=60, min_confidence=0.0, since=0) # otp OR link
67
+ result = client.wait_for_otp("inbox_abc123", timeout=60, min_confidence=0.0, since=None)
68
+ result = client.wait_for_link("inbox_abc123", timeout=60, since=None)
69
+ result = client.wait("inbox_abc123", timeout=60, min_confidence=0.0, since=None) # otp OR link
70
+ ```
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)
52
78
  ```
53
79
 
54
80
  All three return a `WaitResult` and take keyword-only arguments after
@@ -57,9 +83,16 @@ call with no extra arguments for the common case.
57
83
 
58
84
  | Method | Keyword args (all optional, keyword-only) | Returns |
59
85
  | --- | --- | --- |
60
- | `wait_for_otp(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=0` | `WaitResult` |
61
- | `wait_for_link(inbox_id, ...)` | `timeout=60`, `since=0` | `WaitResult` |
62
- | `wait(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=0` | `WaitResult` (otp OR link) |
86
+ | `wait_for_otp(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=None` | `WaitResult` |
87
+ | `wait_for_link(inbox_id, ...)` | `timeout=60`, `since=None` | `WaitResult` |
88
+ | `wait(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=None` | `WaitResult` (otp OR link) |
89
+
90
+ `since=None` (the default) omits the param, so the server uses its own
91
+ default: the request start time — a code that arrives during this call is
92
+ caught, and a stale code already in a reused inbox is not. Pass `since=0` to
93
+ restore the old "any message already in the inbox" behaviour (useful right
94
+ after `create_inbox`, where there's nothing stale to match), or a message id
95
+ / timestamp to continue after a specific point.
63
96
 
64
97
  `WaitResult` exposes `.otp`, `.confidence`, `.magic_link` (and `.link` as an
65
98
  alias), plus the full `.message` dict. `str(result)` is the OTP (or the link).
@@ -78,11 +111,16 @@ Semantics:
78
111
  `MailsocketError` (base) with subclasses:
79
112
 
80
113
  - `AuthError` — HTTP 401 (bad/missing key).
81
- - `NotFound` — HTTP 404.
114
+ - `NotFound` — HTTP 404 (also an unknown, deleted or foreign inbox in
115
+ `send_test_code`).
82
116
  - `RateLimited` — HTTP 429, carries `.subcode` (`rate_limited`,
83
117
  `too_many_wait_requests`, `wait_capacity`) and `.retry_after`.
84
118
  - `WaitTimeout` — no matching message within the overall deadline.
85
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
+
86
124
  ## Develop
87
125
 
88
126
  ```bash
@@ -97,4 +135,13 @@ invalid id never reaches the network.
97
135
 
98
136
  ## Changelog
99
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`.
142
+ - **0.2.0** — BEHAVIOUR CHANGE: `wait_for_otp`/`wait_for_link`/`wait` now
143
+ default `since=None`, which omits the param so the server uses the
144
+ request start time, instead of the old `since=0` ("any message already
145
+ in the inbox"). The old default silently returned a STALE OTP/link from a
146
+ reused inbox. Pass `since=0` explicitly to keep the old behaviour.
100
147
  - **0.1.3** — `_seg` now rejects an empty id or a bare `.`/`..` id with a `MailsocketError` (`code="invalid_id"`) before any request is made, matching the TypeScript SDK's contract.
@@ -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.1.3"
6
+ __version__ = "0.3.0"
7
7
 
8
8
  __all__ = [
9
9
  "Client",
@@ -10,6 +10,7 @@ import time as _time
10
10
  import urllib.error
11
11
  import urllib.parse
12
12
  import urllib.request
13
+ from dataclasses import dataclass, field
13
14
 
14
15
  from .errors import AuthError, MailsocketError, NotFound, RateLimited, WaitTimeout
15
16
 
@@ -79,14 +80,30 @@ class Page:
79
80
  return f"Page(data={self.data!r}, pagination={self.pagination!r})"
80
81
 
81
82
 
83
+ @dataclass(frozen=True, eq=False)
82
84
  class WaitResult:
83
- """The outcome of a ``wait_for_otp`` / ``wait_for_link`` / ``wait`` call."""
85
+ """The outcome of a ``wait_for_otp`` / ``wait_for_link`` / ``wait`` call.
86
+
87
+ #31 (trust-audit-OUT.md): a frozen dataclass, backward compatible with
88
+ the plain-class shape it replaces — same four public attributes
89
+ (``.message``, ``.otp``, ``.confidence``, ``.magic_link``), the derived
90
+ ``.link`` property, and the same ``repr()``/``str()``. Instances are now
91
+ immutable (``frozen=True``); ``eq=False`` keeps equality/hashing
92
+ identity-based exactly as the old plain class, since ``message`` is a
93
+ dict and value-equality would make ``hash()`` raise ``TypeError``.
94
+ """
95
+
96
+ message: dict
97
+ otp: str | None = field(init=False)
98
+ confidence: float | None = field(init=False)
99
+ magic_link: str | None = field(init=False)
84
100
 
85
- def __init__(self, message: dict):
86
- self.message = message
87
- self.otp = message.get("otp")
88
- self.confidence = message.get("otp_confidence")
89
- self.magic_link = message.get("magic_link")
101
+ def __post_init__(self):
102
+ # frozen=True disallows plain attribute assignment even inside
103
+ # __post_init__, hence object.__setattr__.
104
+ object.__setattr__(self, "otp", self.message.get("otp"))
105
+ object.__setattr__(self, "confidence", self.message.get("otp_confidence"))
106
+ object.__setattr__(self, "magic_link", self.message.get("magic_link"))
90
107
 
91
108
  @property
92
109
  def link(self):
@@ -184,6 +201,25 @@ class Client:
184
201
  payload = self._request("GET", f"/messages/{_seg(message_id)}")
185
202
  return payload["data"]
186
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
+
187
223
  # -- the moat -------------------------------------------------------------
188
224
 
189
225
  def wait_for_otp(
@@ -192,9 +228,16 @@ class Client:
192
228
  *,
193
229
  timeout: float = 60.0,
194
230
  min_confidence: float = 0.0,
195
- since=0,
231
+ since=None,
196
232
  ) -> WaitResult:
197
- """Wait for an OTP. Returns a :class:`WaitResult` with ``.otp`` etc."""
233
+ """Wait for an OTP. Returns a :class:`WaitResult` with ``.otp`` etc.
234
+
235
+ ``since`` defaults to ``None``, which omits the param entirely so the
236
+ server's own default applies (the request start time) — a caller
237
+ only sees messages that arrive during this call, never a stale OTP
238
+ already sitting in a reused inbox. Pass ``since=0`` to restore the
239
+ old behaviour: match any message already in the inbox.
240
+ """
198
241
  return self._wait(
199
242
  inbox_id,
200
243
  require="otp",
@@ -203,8 +246,12 @@ class Client:
203
246
  since=since,
204
247
  )
205
248
 
206
- def wait_for_link(self, inbox_id: str, *, timeout: float = 60.0, since=0) -> WaitResult:
207
- """Wait for a magic link. Returns a :class:`WaitResult` with ``.link``."""
249
+ def wait_for_link(self, inbox_id: str, *, timeout: float = 60.0, since=None) -> WaitResult:
250
+ """Wait for a magic link. Returns a :class:`WaitResult` with ``.link``.
251
+
252
+ ``since`` defaults to ``None`` (omitted -> server uses the request
253
+ start time). Pass ``since=0`` to include messages already in the inbox.
254
+ """
208
255
  return self._wait(
209
256
  inbox_id, require="link", timeout=timeout, min_confidence=None, since=since
210
257
  )
@@ -215,9 +262,13 @@ class Client:
215
262
  *,
216
263
  timeout: float = 60.0,
217
264
  min_confidence: float = 0.0,
218
- since=0,
265
+ since=None,
219
266
  ) -> WaitResult:
220
- """Wait for either an OTP or a magic link."""
267
+ """Wait for either an OTP or a magic link.
268
+
269
+ ``since`` defaults to ``None`` (omitted -> server uses the request
270
+ start time). Pass ``since=0`` to include messages already in the inbox.
271
+ """
221
272
  return self._wait(
222
273
  inbox_id,
223
274
  require="any",
@@ -288,7 +339,7 @@ class Client:
288
339
  url += "?" + urllib.parse.urlencode(params, doseq=True)
289
340
  headers = {
290
341
  "Accept": "application/json",
291
- "User-Agent": "mailsocket-python/0.1.3",
342
+ "User-Agent": "mailsocket-python/0.3.0",
292
343
  }
293
344
  headers.update(self._extra_headers)
294
345
  # Set last so nothing in extra_headers can ever replace it.
File without changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mailsocket
3
- Version: 0.1.3
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
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3.10
17
17
  Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Topic :: Communications :: Email
20
+ Classifier: Typing :: Typed
20
21
  Requires-Python: >=3.9
21
22
  Description-Content-Type: text/markdown
22
23
  License-File: LICENSE
@@ -30,17 +31,34 @@ The official Python client for the [mailsocket](https://mailsocket.app) v1 REST
30
31
  Ephemeral email inboxes, message retrieval, and — the whole point — **wait for the OTP in one line**.
31
32
 
32
33
  ```python
34
+ import time
35
+
33
36
  from mailsocket import Client
34
37
 
35
38
  client = Client("ms_live_...") # your API key from the dashboard
36
39
  inbox = client.create_inbox(label="signup") # -> {"id": "inbox_...", "address": "..."}
37
40
 
41
+ started = time.time() # take the cutoff BEFORE the trigger
38
42
  # hand inbox["address"] to whatever form sends the code, then:
39
- 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
40
44
  print(result.otp) # "123456"
41
45
  print(result.confidence) # 0.95
42
46
  ```
43
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
+
44
62
  No polling loop. No regex over the email body. `wait_for_otp` long-polls the
45
63
  server (which already knows how to extract the code) and hands back a
46
64
  deterministic result with a confidence score.
@@ -68,13 +86,22 @@ client = Client(api_key, base_url="https://dash.mailsocket.app/api/v1")
68
86
  | `list_messages(inbox_id, has_otp=None, subject_contains=None, sender=None, ...)` | `Page` — `sender` maps to the API's `from` filter |
69
87
  | `get_latest(inbox_id)` | `dict` — newest message |
70
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) |
71
90
 
72
91
  ### The moat
73
92
 
74
93
  ```python
75
- result = client.wait_for_otp("inbox_abc123", timeout=60, min_confidence=0.0, since=0)
76
- result = client.wait_for_link("inbox_abc123", timeout=60, since=0)
77
- result = client.wait("inbox_abc123", timeout=60, min_confidence=0.0, since=0) # otp OR link
94
+ result = client.wait_for_otp("inbox_abc123", timeout=60, min_confidence=0.0, since=None)
95
+ result = client.wait_for_link("inbox_abc123", timeout=60, since=None)
96
+ result = client.wait("inbox_abc123", timeout=60, min_confidence=0.0, since=None) # otp OR link
97
+ ```
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)
78
105
  ```
79
106
 
80
107
  All three return a `WaitResult` and take keyword-only arguments after
@@ -83,9 +110,16 @@ call with no extra arguments for the common case.
83
110
 
84
111
  | Method | Keyword args (all optional, keyword-only) | Returns |
85
112
  | --- | --- | --- |
86
- | `wait_for_otp(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=0` | `WaitResult` |
87
- | `wait_for_link(inbox_id, ...)` | `timeout=60`, `since=0` | `WaitResult` |
88
- | `wait(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=0` | `WaitResult` (otp OR link) |
113
+ | `wait_for_otp(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=None` | `WaitResult` |
114
+ | `wait_for_link(inbox_id, ...)` | `timeout=60`, `since=None` | `WaitResult` |
115
+ | `wait(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=None` | `WaitResult` (otp OR link) |
116
+
117
+ `since=None` (the default) omits the param, so the server uses its own
118
+ default: the request start time — a code that arrives during this call is
119
+ caught, and a stale code already in a reused inbox is not. Pass `since=0` to
120
+ restore the old "any message already in the inbox" behaviour (useful right
121
+ after `create_inbox`, where there's nothing stale to match), or a message id
122
+ / timestamp to continue after a specific point.
89
123
 
90
124
  `WaitResult` exposes `.otp`, `.confidence`, `.magic_link` (and `.link` as an
91
125
  alias), plus the full `.message` dict. `str(result)` is the OTP (or the link).
@@ -104,11 +138,16 @@ Semantics:
104
138
  `MailsocketError` (base) with subclasses:
105
139
 
106
140
  - `AuthError` — HTTP 401 (bad/missing key).
107
- - `NotFound` — HTTP 404.
141
+ - `NotFound` — HTTP 404 (also an unknown, deleted or foreign inbox in
142
+ `send_test_code`).
108
143
  - `RateLimited` — HTTP 429, carries `.subcode` (`rate_limited`,
109
144
  `too_many_wait_requests`, `wait_capacity`) and `.retry_after`.
110
145
  - `WaitTimeout` — no matching message within the overall deadline.
111
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
+
112
151
  ## Develop
113
152
 
114
153
  ```bash
@@ -123,4 +162,13 @@ invalid id never reaches the network.
123
162
 
124
163
  ## Changelog
125
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`.
169
+ - **0.2.0** — BEHAVIOUR CHANGE: `wait_for_otp`/`wait_for_link`/`wait` now
170
+ default `since=None`, which omits the param so the server uses the
171
+ request start time, instead of the old `since=0` ("any message already
172
+ in the inbox"). The old default silently returned a STALE OTP/link from a
173
+ reused inbox. Pass `since=0` explicitly to keep the old behaviour.
126
174
  - **0.1.3** — `_seg` now rejects an empty id or a bare `.`/`..` id with a `MailsocketError` (`code="invalid_id"`) before any request is made, matching the TypeScript SDK's contract.
@@ -4,6 +4,7 @@ pyproject.toml
4
4
  mailsocket/__init__.py
5
5
  mailsocket/client.py
6
6
  mailsocket/errors.py
7
+ mailsocket/py.typed
7
8
  mailsocket.egg-info/PKG-INFO
8
9
  mailsocket.egg-info/SOURCES.txt
9
10
  mailsocket.egg-info/dependency_links.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mailsocket"
7
- version = "0.1.3"
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"
@@ -21,6 +21,7 @@ classifiers = [
21
21
  "Programming Language :: Python :: 3.11",
22
22
  "Programming Language :: Python :: 3.12",
23
23
  "Topic :: Communications :: Email",
24
+ "Typing :: Typed",
24
25
  ]
25
26
  dependencies = []
26
27
 
@@ -36,6 +37,9 @@ Issues = "https://github.com/mailsocket/mailsocket-sdk/issues"
36
37
  [tool.setuptools]
37
38
  packages = ["mailsocket"]
38
39
 
40
+ [tool.setuptools.package-data]
41
+ mailsocket = ["py.typed"]
42
+
39
43
  [tool.pytest.ini_options]
40
44
  testpaths = ["tests"]
41
45
  pythonpath = ["."]
@@ -1,6 +1,7 @@
1
1
  """Offline unit tests for the mailsocket Python SDK (mocked transport, no network)."""
2
2
 
3
3
  import json
4
+ import urllib.parse
4
5
 
5
6
  import pytest
6
7
 
@@ -109,6 +110,70 @@ def test_wait_for_otp_returns_on_first_200(monkeypatch):
109
110
  assert len(transport.calls) == 1
110
111
 
111
112
 
113
+ def test_wait_result_equality_and_hash_are_identity_based():
114
+ """R2 FIX 3 (GPT review): frozen=True makes WaitResult immutable, but
115
+ equality/hash must stay identity-based exactly as the old plain class —
116
+ value-equality would make hash() raise TypeError because ``message``
117
+ is an unhashable dict."""
118
+ a = WaitResult(dict(OTP_MESSAGE))
119
+ b = WaitResult(dict(OTP_MESSAGE))
120
+
121
+ assert a == a
122
+ assert a != b # same field values, but distinct instances
123
+ assert hash(a) == hash(a)
124
+ assert hash(a) != hash(b) or a is b # identity-based hash, no collision assumed
125
+ assert {a, b} == {a, b} # hashable: usable in a set/dict key
126
+
127
+
128
+ def test_wait_for_otp_default_since_is_omitted(monkeypatch):
129
+ """since=None default must OMIT the `since` param so the server uses the
130
+ request start time, not match a stale message already in a reused inbox.
131
+ Revert-proof: restoring since=0 as the default fails this test."""
132
+ client, transport = make_client([(200, {}, {"data": OTP_MESSAGE})])
133
+ _monkeypatch_transport(monkeypatch, client, transport)
134
+
135
+ client.wait_for_otp("inbox_abc", timeout=60)
136
+
137
+ _, url = transport.calls[0]
138
+ query = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)
139
+ assert "since" not in query
140
+
141
+
142
+ def test_wait_for_link_default_since_is_omitted(monkeypatch):
143
+ client, transport = make_client([(200, {}, {"data": LINK_MESSAGE})])
144
+ _monkeypatch_transport(monkeypatch, client, transport)
145
+
146
+ client.wait_for_link("inbox_abc", timeout=60)
147
+
148
+ _, url = transport.calls[0]
149
+ query = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)
150
+ assert "since" not in query
151
+
152
+
153
+ def test_wait_default_since_is_omitted(monkeypatch):
154
+ client, transport = make_client([(200, {}, {"data": OTP_MESSAGE})])
155
+ _monkeypatch_transport(monkeypatch, client, transport)
156
+
157
+ client.wait("inbox_abc", timeout=60)
158
+
159
+ _, url = transport.calls[0]
160
+ query = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)
161
+ assert "since" not in query
162
+
163
+
164
+ def test_wait_for_otp_explicit_since_zero_still_sent(monkeypatch):
165
+ """Explicit since=0 ("include messages already in the inbox") must still
166
+ be forwarded as a literal 0 on the wire."""
167
+ client, transport = make_client([(200, {}, {"data": OTP_MESSAGE})])
168
+ _monkeypatch_transport(monkeypatch, client, transport)
169
+
170
+ client.wait_for_otp("inbox_abc", timeout=60, since=0)
171
+
172
+ _, url = transport.calls[0]
173
+ query = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)
174
+ assert query["since"] == ["0"]
175
+
176
+
112
177
  def test_wait_keeps_polling_through_204s_then_returns(monkeypatch):
113
178
  responses = [
114
179
  (204, {}, None),
@@ -492,6 +557,7 @@ def test_invalid_id_rejected_before_any_request_hits_real_server(raw):
492
557
  lambda: client.wait_for_otp(raw, timeout=5),
493
558
  lambda: client.wait_for_link(raw, timeout=5),
494
559
  lambda: client.wait(raw, timeout=5),
560
+ lambda: client.send_test_code(raw),
495
561
  ]
496
562
  for call in calls:
497
563
  with pytest.raises(MailsocketError) as exc_info:
@@ -510,13 +576,13 @@ def test_version_exported():
510
576
 
511
577
 
512
578
  def test_metadata_consistency_across_files():
513
- """0.1.3 must match everywhere: pyproject, __version__, user agent."""
579
+ """Version must match everywhere: pyproject, __version__, user agent."""
514
580
  import re
515
581
  import tomllib
516
582
  from pathlib import Path
517
583
 
518
584
  root = Path(__file__).resolve().parents[1]
519
- expected = "0.1.3"
585
+ expected = "0.3.0"
520
586
 
521
587
  pyproject = tomllib.loads((root / "pyproject.toml").read_text())
522
588
  assert pyproject["project"]["version"] == expected
@@ -592,3 +658,138 @@ def test_no_extra_headers_is_backwards_compatible(monkeypatch):
592
658
  sent = capture.headers[0]
593
659
  assert set(sent) >= {"authorization", "accept", "user-agent"}
594
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