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.
- {mailsocket-0.1.3 → mailsocket-0.3.0}/PKG-INFO +57 -9
- {mailsocket-0.1.3 → mailsocket-0.3.0}/README.md +55 -8
- {mailsocket-0.1.3 → mailsocket-0.3.0}/mailsocket/__init__.py +1 -1
- {mailsocket-0.1.3 → mailsocket-0.3.0}/mailsocket/client.py +64 -13
- mailsocket-0.3.0/mailsocket/py.typed +0 -0
- {mailsocket-0.1.3 → mailsocket-0.3.0}/mailsocket.egg-info/PKG-INFO +57 -9
- {mailsocket-0.1.3 → mailsocket-0.3.0}/mailsocket.egg-info/SOURCES.txt +1 -0
- {mailsocket-0.1.3 → mailsocket-0.3.0}/pyproject.toml +5 -1
- {mailsocket-0.1.3 → mailsocket-0.3.0}/tests/test_client.py +203 -2
- {mailsocket-0.1.3 → mailsocket-0.3.0}/LICENSE +0 -0
- {mailsocket-0.1.3 → mailsocket-0.3.0}/mailsocket/errors.py +0 -0
- {mailsocket-0.1.3 → mailsocket-0.3.0}/mailsocket.egg-info/dependency_links.txt +0 -0
- {mailsocket-0.1.3 → mailsocket-0.3.0}/mailsocket.egg-info/requires.txt +0 -0
- {mailsocket-0.1.3 → mailsocket-0.3.0}/mailsocket.egg-info/top_level.txt +0 -0
- {mailsocket-0.1.3 → mailsocket-0.3.0}/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.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"])
|
|
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=
|
|
76
|
-
result = client.wait_for_link("inbox_abc123", timeout=60, since=
|
|
77
|
-
result = client.wait("inbox_abc123", timeout=60, min_confidence=0.0, since=
|
|
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=
|
|
87
|
-
| `wait_for_link(inbox_id, ...)` | `timeout=60`, `since=
|
|
88
|
-
| `wait(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=
|
|
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"])
|
|
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=
|
|
50
|
-
result = client.wait_for_link("inbox_abc123", timeout=60, since=
|
|
51
|
-
result = client.wait("inbox_abc123", timeout=60, min_confidence=0.0, since=
|
|
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=
|
|
61
|
-
| `wait_for_link(inbox_id, ...)` | `timeout=60`, `since=
|
|
62
|
-
| `wait(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=
|
|
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.
|
|
@@ -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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
self
|
|
89
|
-
self
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
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.
|
|
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"])
|
|
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=
|
|
76
|
-
result = client.wait_for_link("inbox_abc123", timeout=60, since=
|
|
77
|
-
result = client.wait("inbox_abc123", timeout=60, min_confidence=0.0, since=
|
|
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=
|
|
87
|
-
| `wait_for_link(inbox_id, ...)` | `timeout=60`, `since=
|
|
88
|
-
| `wait(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=
|
|
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,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mailsocket"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Official Python client for the mailsocket REST API — one line to wait for an OTP."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -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
|
-
"""
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|