mailsocket 0.1.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.0/LICENSE +21 -0
- mailsocket-0.1.0/PKG-INFO +108 -0
- mailsocket-0.1.0/README.md +84 -0
- mailsocket-0.1.0/mailsocket/__init__.py +17 -0
- mailsocket-0.1.0/mailsocket/client.py +329 -0
- mailsocket-0.1.0/mailsocket/errors.py +47 -0
- mailsocket-0.1.0/mailsocket.egg-info/PKG-INFO +108 -0
- mailsocket-0.1.0/mailsocket.egg-info/SOURCES.txt +12 -0
- mailsocket-0.1.0/mailsocket.egg-info/dependency_links.txt +1 -0
- mailsocket-0.1.0/mailsocket.egg-info/requires.txt +3 -0
- mailsocket-0.1.0/mailsocket.egg-info/top_level.txt +1 -0
- mailsocket-0.1.0/pyproject.toml +39 -0
- mailsocket-0.1.0/setup.cfg +4 -0
- mailsocket-0.1.0/tests/test_client.py +356 -0
mailsocket-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mailsocket
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mailsocket
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the mailsocket REST API — one line to wait for an OTP.
|
|
5
|
+
Author: mailsocket
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://mailsocket.app
|
|
8
|
+
Project-URL: Documentation, https://dash.mailsocket.app/docs
|
|
9
|
+
Keywords: mailsocket,email,otp,inbox,magic-link
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Communications :: Email
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# mailsocket — Python SDK
|
|
26
|
+
|
|
27
|
+
The official Python client for the [mailsocket](https://mailsocket.app) v1 REST API.
|
|
28
|
+
Ephemeral email inboxes, message retrieval, and — the whole point — **wait for the OTP in one line**.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from mailsocket import Client
|
|
32
|
+
|
|
33
|
+
client = Client("ms_live_...") # your API key from the dashboard
|
|
34
|
+
inbox = client.create_inbox(label="signup") # -> {"id": "inbox_...", "address": "..."}
|
|
35
|
+
|
|
36
|
+
# hand inbox["address"] to whatever form sends the code, then:
|
|
37
|
+
result = client.wait_for_otp(inbox["id"]) # blocks up to 60s
|
|
38
|
+
print(result.otp) # "123456"
|
|
39
|
+
print(result.confidence) # 0.95
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
No polling loop. No regex over the email body. `wait_for_otp` long-polls the
|
|
43
|
+
server (which already knows how to extract the code) and hands back a
|
|
44
|
+
deterministic result with a confidence score.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install mailsocket
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Zero runtime dependencies — pure standard library (`urllib`). Python 3.9+.
|
|
53
|
+
|
|
54
|
+
## API
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
client = Client(api_key, base_url="https://dash.mailsocket.app/api/v1")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
| Method | Returns |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| `create_inbox(label=None)` | `dict` — the inbox (`id`, `address`, ...) |
|
|
63
|
+
| `list_inboxes(limit=None, cursor=None)` | `Page` — `.data` list + `.next_cursor` / `.has_more` |
|
|
64
|
+
| `get_inbox(inbox_id)` | `dict` — inbox plus `message_count_month`, `webhook_configured` |
|
|
65
|
+
| `delete_inbox(inbox_id)` | `None` |
|
|
66
|
+
| `list_messages(inbox_id, has_otp=None, subject_contains=None, sender=None, ...)` | `Page` — `sender` maps to the API's `from` filter |
|
|
67
|
+
| `get_latest(inbox_id)` | `dict` — newest message |
|
|
68
|
+
| `get_message(message_id)` | `dict` — full message |
|
|
69
|
+
|
|
70
|
+
### The moat
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
result = client.wait_for_otp(inbox_id, *, timeout=60, min_confidence=0.0, since=0) -> WaitResult
|
|
74
|
+
result = client.wait_for_link(inbox_id, *, timeout=60, since=0) -> WaitResult
|
|
75
|
+
result = client.wait(inbox_id, *, timeout=60, min_confidence=0.0, since=0) -> WaitResult # otp OR link
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`WaitResult` exposes `.otp`, `.confidence`, `.magic_link` (and `.link` as an
|
|
79
|
+
alias), plus the full `.message` dict. `str(result)` is the OTP (or the link).
|
|
80
|
+
|
|
81
|
+
Semantics:
|
|
82
|
+
|
|
83
|
+
- Each HTTP call blocks server-side for up to 25s (`timeout` is clamped to
|
|
84
|
+
`[1, 25]`); the SDK re-calls until the **overall** `timeout` (seconds,
|
|
85
|
+
default 60) elapses.
|
|
86
|
+
- `200` → the matching message. `204` → nothing yet, re-call immediately.
|
|
87
|
+
- `429` → honours `Retry-After` and retries within the deadline. `404` → raises.
|
|
88
|
+
- On overall deadline → `WaitTimeout`.
|
|
89
|
+
|
|
90
|
+
## Errors
|
|
91
|
+
|
|
92
|
+
`MailsocketError` (base) with subclasses:
|
|
93
|
+
|
|
94
|
+
- `AuthError` — HTTP 401 (bad/missing key).
|
|
95
|
+
- `NotFound` — HTTP 404.
|
|
96
|
+
- `RateLimited` — HTTP 429, carries `.subcode` (`rate_limited`,
|
|
97
|
+
`too_many_wait_requests`, `wait_capacity`) and `.retry_after`.
|
|
98
|
+
- `WaitTimeout` — no matching message within the overall deadline.
|
|
99
|
+
|
|
100
|
+
## Develop
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
cd sdks/python
|
|
104
|
+
python -m pytest -q
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Tests are fully offline — they monkeypatch `urllib.request.urlopen` with a
|
|
108
|
+
scripted responder, so nothing touches the live API.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# mailsocket — Python SDK
|
|
2
|
+
|
|
3
|
+
The official Python client for the [mailsocket](https://mailsocket.app) v1 REST API.
|
|
4
|
+
Ephemeral email inboxes, message retrieval, and — the whole point — **wait for the OTP in one line**.
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
from mailsocket import Client
|
|
8
|
+
|
|
9
|
+
client = Client("ms_live_...") # your API key from the dashboard
|
|
10
|
+
inbox = client.create_inbox(label="signup") # -> {"id": "inbox_...", "address": "..."}
|
|
11
|
+
|
|
12
|
+
# hand inbox["address"] to whatever form sends the code, then:
|
|
13
|
+
result = client.wait_for_otp(inbox["id"]) # blocks up to 60s
|
|
14
|
+
print(result.otp) # "123456"
|
|
15
|
+
print(result.confidence) # 0.95
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
No polling loop. No regex over the email body. `wait_for_otp` long-polls the
|
|
19
|
+
server (which already knows how to extract the code) and hands back a
|
|
20
|
+
deterministic result with a confidence score.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install mailsocket
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Zero runtime dependencies — pure standard library (`urllib`). Python 3.9+.
|
|
29
|
+
|
|
30
|
+
## API
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
client = Client(api_key, base_url="https://dash.mailsocket.app/api/v1")
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
| Method | Returns |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| `create_inbox(label=None)` | `dict` — the inbox (`id`, `address`, ...) |
|
|
39
|
+
| `list_inboxes(limit=None, cursor=None)` | `Page` — `.data` list + `.next_cursor` / `.has_more` |
|
|
40
|
+
| `get_inbox(inbox_id)` | `dict` — inbox plus `message_count_month`, `webhook_configured` |
|
|
41
|
+
| `delete_inbox(inbox_id)` | `None` |
|
|
42
|
+
| `list_messages(inbox_id, has_otp=None, subject_contains=None, sender=None, ...)` | `Page` — `sender` maps to the API's `from` filter |
|
|
43
|
+
| `get_latest(inbox_id)` | `dict` — newest message |
|
|
44
|
+
| `get_message(message_id)` | `dict` — full message |
|
|
45
|
+
|
|
46
|
+
### The moat
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
result = client.wait_for_otp(inbox_id, *, timeout=60, min_confidence=0.0, since=0) -> WaitResult
|
|
50
|
+
result = client.wait_for_link(inbox_id, *, timeout=60, since=0) -> WaitResult
|
|
51
|
+
result = client.wait(inbox_id, *, timeout=60, min_confidence=0.0, since=0) -> WaitResult # otp OR link
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`WaitResult` exposes `.otp`, `.confidence`, `.magic_link` (and `.link` as an
|
|
55
|
+
alias), plus the full `.message` dict. `str(result)` is the OTP (or the link).
|
|
56
|
+
|
|
57
|
+
Semantics:
|
|
58
|
+
|
|
59
|
+
- Each HTTP call blocks server-side for up to 25s (`timeout` is clamped to
|
|
60
|
+
`[1, 25]`); the SDK re-calls until the **overall** `timeout` (seconds,
|
|
61
|
+
default 60) elapses.
|
|
62
|
+
- `200` → the matching message. `204` → nothing yet, re-call immediately.
|
|
63
|
+
- `429` → honours `Retry-After` and retries within the deadline. `404` → raises.
|
|
64
|
+
- On overall deadline → `WaitTimeout`.
|
|
65
|
+
|
|
66
|
+
## Errors
|
|
67
|
+
|
|
68
|
+
`MailsocketError` (base) with subclasses:
|
|
69
|
+
|
|
70
|
+
- `AuthError` — HTTP 401 (bad/missing key).
|
|
71
|
+
- `NotFound` — HTTP 404.
|
|
72
|
+
- `RateLimited` — HTTP 429, carries `.subcode` (`rate_limited`,
|
|
73
|
+
`too_many_wait_requests`, `wait_capacity`) and `.retry_after`.
|
|
74
|
+
- `WaitTimeout` — no matching message within the overall deadline.
|
|
75
|
+
|
|
76
|
+
## Develop
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
cd sdks/python
|
|
80
|
+
python -m pytest -q
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Tests are fully offline — they monkeypatch `urllib.request.urlopen` with a
|
|
84
|
+
scripted responder, so nothing touches the live API.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Official Python SDK for the mailsocket v1 REST API."""
|
|
2
|
+
|
|
3
|
+
from .client import Client, Page, WaitResult
|
|
4
|
+
from .errors import AuthError, MailsocketError, NotFound, RateLimited, WaitTimeout
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Client",
|
|
10
|
+
"Page",
|
|
11
|
+
"WaitResult",
|
|
12
|
+
"MailsocketError",
|
|
13
|
+
"AuthError",
|
|
14
|
+
"NotFound",
|
|
15
|
+
"RateLimited",
|
|
16
|
+
"WaitTimeout",
|
|
17
|
+
]
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Synchronous HTTP client for the mailsocket v1 REST API.
|
|
2
|
+
|
|
3
|
+
Pure standard library (``urllib``) — no runtime dependencies.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import time as _time
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request
|
|
13
|
+
|
|
14
|
+
from .errors import AuthError, MailsocketError, NotFound, RateLimited, WaitTimeout
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://dash.mailsocket.app/api/v1"
|
|
17
|
+
WAIT_TIMEOUT_MAX = 25 # seconds — the server clamps the per-call wait to [1, 25]
|
|
18
|
+
WAIT_SOCKET_BUFFER = 10 # seconds of client-side slack above the server block
|
|
19
|
+
DEFAULT_RETRY_AFTER = 1.0 # seconds, when a 429 carries no Retry-After header
|
|
20
|
+
|
|
21
|
+
__all__ = ["Client", "Page", "WaitResult"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _monotonic() -> float:
|
|
25
|
+
return _time.monotonic()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _sleep(seconds: float) -> None:
|
|
29
|
+
_time.sleep(seconds)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Page:
|
|
33
|
+
"""A page of results plus its pagination metadata.
|
|
34
|
+
|
|
35
|
+
Behaves like a list of ``.data`` while exposing ``.pagination``,
|
|
36
|
+
``.next_cursor`` and ``.has_more``.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, data: list, pagination: dict | None):
|
|
40
|
+
self.data = data
|
|
41
|
+
self.pagination = pagination or {}
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def next_cursor(self):
|
|
45
|
+
return self.pagination.get("next_cursor")
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def has_more(self) -> bool:
|
|
49
|
+
return bool(self.pagination.get("has_more"))
|
|
50
|
+
|
|
51
|
+
def __len__(self) -> int:
|
|
52
|
+
return len(self.data)
|
|
53
|
+
|
|
54
|
+
def __iter__(self):
|
|
55
|
+
return iter(self.data)
|
|
56
|
+
|
|
57
|
+
def __getitem__(self, index):
|
|
58
|
+
return self.data[index]
|
|
59
|
+
|
|
60
|
+
def __repr__(self) -> str:
|
|
61
|
+
return f"Page(data={self.data!r}, pagination={self.pagination!r})"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class WaitResult:
|
|
65
|
+
"""The outcome of a ``wait_for_otp`` / ``wait_for_link`` / ``wait`` call."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, message: dict):
|
|
68
|
+
self.message = message
|
|
69
|
+
self.otp = message.get("otp")
|
|
70
|
+
self.confidence = message.get("otp_confidence")
|
|
71
|
+
self.magic_link = message.get("magic_link")
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def link(self):
|
|
75
|
+
"""Alias for ``magic_link``."""
|
|
76
|
+
return self.magic_link
|
|
77
|
+
|
|
78
|
+
def __repr__(self) -> str:
|
|
79
|
+
return (
|
|
80
|
+
f"WaitResult(otp={self.otp!r}, confidence={self.confidence!r}, "
|
|
81
|
+
f"magic_link={self.magic_link!r})"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def __str__(self) -> str:
|
|
85
|
+
return self.otp if self.otp is not None else (self.magic_link or "")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Client:
|
|
89
|
+
"""A thin, synchronous client for the mailsocket v1 REST API."""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
api_key: str,
|
|
94
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
95
|
+
*,
|
|
96
|
+
request_timeout: float = 30.0,
|
|
97
|
+
):
|
|
98
|
+
if not api_key:
|
|
99
|
+
raise ValueError("api_key is required")
|
|
100
|
+
self._api_key = api_key
|
|
101
|
+
self._base_url = base_url.rstrip("/")
|
|
102
|
+
self._request_timeout = request_timeout
|
|
103
|
+
|
|
104
|
+
# -- public API -----------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def create_inbox(self, label: str | None = None) -> dict:
|
|
107
|
+
body = {} if label is None else {"label": label}
|
|
108
|
+
payload = self._request("POST", "/inboxes", json_body=body)
|
|
109
|
+
return payload["data"]
|
|
110
|
+
|
|
111
|
+
def list_inboxes(self, limit: int | None = None, cursor: str | None = None) -> Page:
|
|
112
|
+
payload = self._request("GET", "/inboxes", params=self._page_params(limit, cursor))
|
|
113
|
+
return Page(payload["data"], payload.get("pagination"))
|
|
114
|
+
|
|
115
|
+
def get_inbox(self, inbox_id: str) -> dict:
|
|
116
|
+
payload = self._request("GET", f"/inboxes/{inbox_id}")
|
|
117
|
+
return payload["data"]
|
|
118
|
+
|
|
119
|
+
def delete_inbox(self, inbox_id: str) -> None:
|
|
120
|
+
self._request("DELETE", f"/inboxes/{inbox_id}")
|
|
121
|
+
|
|
122
|
+
def list_messages(
|
|
123
|
+
self,
|
|
124
|
+
inbox_id: str,
|
|
125
|
+
has_otp: bool | None = None,
|
|
126
|
+
subject_contains: str | None = None,
|
|
127
|
+
sender: str | None = None,
|
|
128
|
+
limit: int | None = None,
|
|
129
|
+
cursor: str | None = None,
|
|
130
|
+
) -> Page:
|
|
131
|
+
params = self._page_params(limit, cursor)
|
|
132
|
+
if has_otp is not None:
|
|
133
|
+
params["has_otp"] = "true" if has_otp else "false"
|
|
134
|
+
if subject_contains:
|
|
135
|
+
params["subject_contains"] = subject_contains
|
|
136
|
+
if sender:
|
|
137
|
+
params["from"] = sender # the API names this filter ``from``
|
|
138
|
+
payload = self._request("GET", f"/inboxes/{inbox_id}/messages", params=params)
|
|
139
|
+
return Page(payload["data"], payload.get("pagination"))
|
|
140
|
+
|
|
141
|
+
def get_latest(self, inbox_id: str) -> dict:
|
|
142
|
+
payload = self._request("GET", f"/inboxes/{inbox_id}/messages/latest")
|
|
143
|
+
return payload["data"]
|
|
144
|
+
|
|
145
|
+
def get_message(self, message_id: str) -> dict:
|
|
146
|
+
payload = self._request("GET", f"/messages/{message_id}")
|
|
147
|
+
return payload["data"]
|
|
148
|
+
|
|
149
|
+
# -- the moat -------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def wait_for_otp(
|
|
152
|
+
self,
|
|
153
|
+
inbox_id: str,
|
|
154
|
+
*,
|
|
155
|
+
timeout: float = 60.0,
|
|
156
|
+
min_confidence: float = 0.0,
|
|
157
|
+
since=0,
|
|
158
|
+
) -> WaitResult:
|
|
159
|
+
"""Wait for an OTP. Returns a :class:`WaitResult` with ``.otp`` etc."""
|
|
160
|
+
return self._wait(
|
|
161
|
+
inbox_id,
|
|
162
|
+
require="otp",
|
|
163
|
+
timeout=timeout,
|
|
164
|
+
min_confidence=min_confidence,
|
|
165
|
+
since=since,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def wait_for_link(self, inbox_id: str, *, timeout: float = 60.0, since=0) -> WaitResult:
|
|
169
|
+
"""Wait for a magic link. Returns a :class:`WaitResult` with ``.link``."""
|
|
170
|
+
return self._wait(
|
|
171
|
+
inbox_id, require="link", timeout=timeout, min_confidence=None, since=since
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def wait(
|
|
175
|
+
self,
|
|
176
|
+
inbox_id: str,
|
|
177
|
+
*,
|
|
178
|
+
timeout: float = 60.0,
|
|
179
|
+
min_confidence: float = 0.0,
|
|
180
|
+
since=0,
|
|
181
|
+
) -> WaitResult:
|
|
182
|
+
"""Wait for either an OTP or a magic link."""
|
|
183
|
+
return self._wait(
|
|
184
|
+
inbox_id,
|
|
185
|
+
require="any",
|
|
186
|
+
timeout=timeout,
|
|
187
|
+
min_confidence=min_confidence,
|
|
188
|
+
since=since,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# -- internals ------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def _wait(
|
|
194
|
+
self,
|
|
195
|
+
inbox_id: str,
|
|
196
|
+
*,
|
|
197
|
+
require: str,
|
|
198
|
+
timeout: float,
|
|
199
|
+
min_confidence: float | None,
|
|
200
|
+
since,
|
|
201
|
+
) -> WaitResult:
|
|
202
|
+
deadline = _monotonic() + float(timeout)
|
|
203
|
+
while True:
|
|
204
|
+
remaining = deadline - _monotonic()
|
|
205
|
+
if remaining <= 0:
|
|
206
|
+
raise WaitTimeout(
|
|
207
|
+
f"No matching message arrived within {timeout:g}s.", code="wait_timeout"
|
|
208
|
+
)
|
|
209
|
+
server_timeout = max(1.0, min(float(WAIT_TIMEOUT_MAX), remaining))
|
|
210
|
+
params = {"timeout": server_timeout, "require": require}
|
|
211
|
+
if since is not None:
|
|
212
|
+
params["since"] = since
|
|
213
|
+
if min_confidence is not None:
|
|
214
|
+
params["min_confidence"] = min_confidence
|
|
215
|
+
|
|
216
|
+
status, headers, body = self._request_http(
|
|
217
|
+
"GET",
|
|
218
|
+
f"/inboxes/{inbox_id}/messages/wait",
|
|
219
|
+
params=params,
|
|
220
|
+
socket_timeout=server_timeout + WAIT_SOCKET_BUFFER,
|
|
221
|
+
)
|
|
222
|
+
payload = self._parse_json(body)
|
|
223
|
+
if status == 200:
|
|
224
|
+
return WaitResult(payload["data"])
|
|
225
|
+
if status == 204:
|
|
226
|
+
continue
|
|
227
|
+
if status == 429:
|
|
228
|
+
retry_after = self._retry_after(headers)
|
|
229
|
+
sleep_for = retry_after if retry_after is not None else DEFAULT_RETRY_AFTER
|
|
230
|
+
if _monotonic() + sleep_for > deadline:
|
|
231
|
+
raise WaitTimeout(
|
|
232
|
+
f"No matching message arrived within {timeout:g}s.", code="wait_timeout"
|
|
233
|
+
)
|
|
234
|
+
_sleep(sleep_for)
|
|
235
|
+
continue
|
|
236
|
+
self._raise_for_status(status, headers, payload)
|
|
237
|
+
|
|
238
|
+
def _request(self, method: str, path: str, *, params: dict | None = None, json_body=None):
|
|
239
|
+
status, headers, body = self._request_http(method, path, params=params, json_body=json_body)
|
|
240
|
+
payload = self._parse_json(body)
|
|
241
|
+
if 200 <= status < 300:
|
|
242
|
+
return payload
|
|
243
|
+
self._raise_for_status(status, headers, payload)
|
|
244
|
+
|
|
245
|
+
def _request_http(self, method, path, *, params=None, json_body=None, socket_timeout=None):
|
|
246
|
+
url = self._base_url + path
|
|
247
|
+
if params:
|
|
248
|
+
url += "?" + urllib.parse.urlencode(params, doseq=True)
|
|
249
|
+
headers = {
|
|
250
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
251
|
+
"Accept": "application/json",
|
|
252
|
+
"User-Agent": "mailsocket-python/0.1.0",
|
|
253
|
+
}
|
|
254
|
+
data = None
|
|
255
|
+
if json_body is not None:
|
|
256
|
+
data = json.dumps(json_body).encode("utf-8")
|
|
257
|
+
headers["Content-Type"] = "application/json"
|
|
258
|
+
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
259
|
+
timeout = socket_timeout if socket_timeout is not None else self._request_timeout
|
|
260
|
+
try:
|
|
261
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
262
|
+
status = response.getcode()
|
|
263
|
+
response_headers = {k.lower(): v for k, v in response.headers.items()}
|
|
264
|
+
body = response.read()
|
|
265
|
+
except urllib.error.HTTPError as exc:
|
|
266
|
+
status = exc.code
|
|
267
|
+
response_headers = {k.lower(): v for k, v in exc.headers.items()}
|
|
268
|
+
body = exc.read()
|
|
269
|
+
except urllib.error.URLError as exc:
|
|
270
|
+
raise MailsocketError(f"Network error: {exc.reason}") from exc
|
|
271
|
+
return status, response_headers, body
|
|
272
|
+
|
|
273
|
+
def _raise_for_status(self, status: int, headers: dict, payload):
|
|
274
|
+
code = None
|
|
275
|
+
message = None
|
|
276
|
+
if isinstance(payload, dict) and isinstance(payload.get("error"), dict):
|
|
277
|
+
err = payload["error"]
|
|
278
|
+
code = err.get("code")
|
|
279
|
+
message = err.get("message")
|
|
280
|
+
if status == 401:
|
|
281
|
+
raise AuthError(
|
|
282
|
+
message or "Authentication required.",
|
|
283
|
+
code=code or "authentication_required",
|
|
284
|
+
status=401,
|
|
285
|
+
)
|
|
286
|
+
if status == 404:
|
|
287
|
+
raise NotFound(
|
|
288
|
+
message or "Resource not found.", code=code or "not_found", status=404
|
|
289
|
+
)
|
|
290
|
+
if status == 429:
|
|
291
|
+
raise RateLimited(
|
|
292
|
+
message or "Rate limit exceeded.",
|
|
293
|
+
code=code or "rate_limited",
|
|
294
|
+
subcode=code,
|
|
295
|
+
retry_after=self._retry_after(headers),
|
|
296
|
+
)
|
|
297
|
+
raise MailsocketError(message or f"HTTP {status}", code=code, status=status)
|
|
298
|
+
|
|
299
|
+
@staticmethod
|
|
300
|
+
def _parse_json(body: bytes) -> dict:
|
|
301
|
+
if not body:
|
|
302
|
+
return {}
|
|
303
|
+
try:
|
|
304
|
+
decoded = json.loads(body.decode("utf-8"))
|
|
305
|
+
except (ValueError, UnicodeDecodeError):
|
|
306
|
+
return {}
|
|
307
|
+
return decoded if isinstance(decoded, dict) else {}
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _retry_after(headers: dict) -> float | None:
|
|
311
|
+
value = headers.get("retry-after")
|
|
312
|
+
if value is None:
|
|
313
|
+
return None
|
|
314
|
+
try:
|
|
315
|
+
seconds = float(value)
|
|
316
|
+
except (TypeError, ValueError):
|
|
317
|
+
return None
|
|
318
|
+
# A negative Retry-After (malformed server/proxy) must never become a
|
|
319
|
+
# negative sleep; clamp to 0 so the loop re-polls immediately instead.
|
|
320
|
+
return max(0.0, seconds)
|
|
321
|
+
|
|
322
|
+
@staticmethod
|
|
323
|
+
def _page_params(limit, cursor) -> dict:
|
|
324
|
+
params = {}
|
|
325
|
+
if limit is not None:
|
|
326
|
+
params["limit"] = limit
|
|
327
|
+
if cursor is not None:
|
|
328
|
+
params["cursor"] = cursor
|
|
329
|
+
return params
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Typed exceptions raised by the mailsocket Python SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MailsocketError(Exception):
|
|
7
|
+
"""Base class for every error the SDK raises."""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self, message: str, *, code: str | None = None, status: int | None = None
|
|
11
|
+
):
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.code = code
|
|
14
|
+
self.status = status
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthError(MailsocketError):
|
|
18
|
+
"""The API key is missing or invalid (HTTP 401)."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NotFound(MailsocketError):
|
|
22
|
+
"""The requested resource does not exist or is not owned (HTTP 404)."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RateLimited(MailsocketError):
|
|
26
|
+
"""A rate limit was hit (HTTP 429).
|
|
27
|
+
|
|
28
|
+
Carries ``subcode`` (the server error code, e.g. ``rate_limited``,
|
|
29
|
+
``too_many_wait_requests`` or ``wait_capacity``) and ``retry_after``
|
|
30
|
+
(seconds to wait, from the ``Retry-After`` header, or ``None``).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
message: str,
|
|
36
|
+
*,
|
|
37
|
+
code: str | None = None,
|
|
38
|
+
subcode: str | None = None,
|
|
39
|
+
retry_after: float | None = None,
|
|
40
|
+
):
|
|
41
|
+
super().__init__(message, code=code, status=429)
|
|
42
|
+
self.subcode = subcode
|
|
43
|
+
self.retry_after = retry_after
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class WaitTimeout(MailsocketError):
|
|
47
|
+
"""No matching message arrived before the overall wait deadline."""
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mailsocket
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the mailsocket REST API — one line to wait for an OTP.
|
|
5
|
+
Author: mailsocket
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://mailsocket.app
|
|
8
|
+
Project-URL: Documentation, https://dash.mailsocket.app/docs
|
|
9
|
+
Keywords: mailsocket,email,otp,inbox,magic-link
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Communications :: Email
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# mailsocket — Python SDK
|
|
26
|
+
|
|
27
|
+
The official Python client for the [mailsocket](https://mailsocket.app) v1 REST API.
|
|
28
|
+
Ephemeral email inboxes, message retrieval, and — the whole point — **wait for the OTP in one line**.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from mailsocket import Client
|
|
32
|
+
|
|
33
|
+
client = Client("ms_live_...") # your API key from the dashboard
|
|
34
|
+
inbox = client.create_inbox(label="signup") # -> {"id": "inbox_...", "address": "..."}
|
|
35
|
+
|
|
36
|
+
# hand inbox["address"] to whatever form sends the code, then:
|
|
37
|
+
result = client.wait_for_otp(inbox["id"]) # blocks up to 60s
|
|
38
|
+
print(result.otp) # "123456"
|
|
39
|
+
print(result.confidence) # 0.95
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
No polling loop. No regex over the email body. `wait_for_otp` long-polls the
|
|
43
|
+
server (which already knows how to extract the code) and hands back a
|
|
44
|
+
deterministic result with a confidence score.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install mailsocket
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Zero runtime dependencies — pure standard library (`urllib`). Python 3.9+.
|
|
53
|
+
|
|
54
|
+
## API
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
client = Client(api_key, base_url="https://dash.mailsocket.app/api/v1")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
| Method | Returns |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| `create_inbox(label=None)` | `dict` — the inbox (`id`, `address`, ...) |
|
|
63
|
+
| `list_inboxes(limit=None, cursor=None)` | `Page` — `.data` list + `.next_cursor` / `.has_more` |
|
|
64
|
+
| `get_inbox(inbox_id)` | `dict` — inbox plus `message_count_month`, `webhook_configured` |
|
|
65
|
+
| `delete_inbox(inbox_id)` | `None` |
|
|
66
|
+
| `list_messages(inbox_id, has_otp=None, subject_contains=None, sender=None, ...)` | `Page` — `sender` maps to the API's `from` filter |
|
|
67
|
+
| `get_latest(inbox_id)` | `dict` — newest message |
|
|
68
|
+
| `get_message(message_id)` | `dict` — full message |
|
|
69
|
+
|
|
70
|
+
### The moat
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
result = client.wait_for_otp(inbox_id, *, timeout=60, min_confidence=0.0, since=0) -> WaitResult
|
|
74
|
+
result = client.wait_for_link(inbox_id, *, timeout=60, since=0) -> WaitResult
|
|
75
|
+
result = client.wait(inbox_id, *, timeout=60, min_confidence=0.0, since=0) -> WaitResult # otp OR link
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`WaitResult` exposes `.otp`, `.confidence`, `.magic_link` (and `.link` as an
|
|
79
|
+
alias), plus the full `.message` dict. `str(result)` is the OTP (or the link).
|
|
80
|
+
|
|
81
|
+
Semantics:
|
|
82
|
+
|
|
83
|
+
- Each HTTP call blocks server-side for up to 25s (`timeout` is clamped to
|
|
84
|
+
`[1, 25]`); the SDK re-calls until the **overall** `timeout` (seconds,
|
|
85
|
+
default 60) elapses.
|
|
86
|
+
- `200` → the matching message. `204` → nothing yet, re-call immediately.
|
|
87
|
+
- `429` → honours `Retry-After` and retries within the deadline. `404` → raises.
|
|
88
|
+
- On overall deadline → `WaitTimeout`.
|
|
89
|
+
|
|
90
|
+
## Errors
|
|
91
|
+
|
|
92
|
+
`MailsocketError` (base) with subclasses:
|
|
93
|
+
|
|
94
|
+
- `AuthError` — HTTP 401 (bad/missing key).
|
|
95
|
+
- `NotFound` — HTTP 404.
|
|
96
|
+
- `RateLimited` — HTTP 429, carries `.subcode` (`rate_limited`,
|
|
97
|
+
`too_many_wait_requests`, `wait_capacity`) and `.retry_after`.
|
|
98
|
+
- `WaitTimeout` — no matching message within the overall deadline.
|
|
99
|
+
|
|
100
|
+
## Develop
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
cd sdks/python
|
|
104
|
+
python -m pytest -q
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Tests are fully offline — they monkeypatch `urllib.request.urlopen` with a
|
|
108
|
+
scripted responder, so nothing touches the live API.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
mailsocket/__init__.py
|
|
5
|
+
mailsocket/client.py
|
|
6
|
+
mailsocket/errors.py
|
|
7
|
+
mailsocket.egg-info/PKG-INFO
|
|
8
|
+
mailsocket.egg-info/SOURCES.txt
|
|
9
|
+
mailsocket.egg-info/dependency_links.txt
|
|
10
|
+
mailsocket.egg-info/requires.txt
|
|
11
|
+
mailsocket.egg-info/top_level.txt
|
|
12
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mailsocket
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mailsocket"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python client for the mailsocket REST API — one line to wait for an OTP."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "mailsocket" }]
|
|
14
|
+
keywords = ["mailsocket", "email", "otp", "inbox", "magic-link"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Topic :: Communications :: Email",
|
|
24
|
+
]
|
|
25
|
+
dependencies = []
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = ["pytest>=7"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://mailsocket.app"
|
|
32
|
+
Documentation = "https://dash.mailsocket.app/docs"
|
|
33
|
+
|
|
34
|
+
[tool.setuptools]
|
|
35
|
+
packages = ["mailsocket"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|
|
39
|
+
pythonpath = ["."]
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Offline unit tests for the mailsocket Python SDK (mocked transport, no network)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import mailsocket
|
|
8
|
+
from mailsocket import AuthError, MailsocketError, NotFound, RateLimited, WaitTimeout
|
|
9
|
+
from mailsocket.client import Client, WaitResult
|
|
10
|
+
|
|
11
|
+
OTP_MESSAGE = {
|
|
12
|
+
"id": "msg_123",
|
|
13
|
+
"inbox_id": "inbox_abc",
|
|
14
|
+
"from": "Acme <no-reply@acme.test>",
|
|
15
|
+
"to": ["user@in.inboxpipe.net"],
|
|
16
|
+
"cc": [],
|
|
17
|
+
"subject": "Your code",
|
|
18
|
+
"received_at": "2026-09-23T12:00:00Z",
|
|
19
|
+
"status": "parsed",
|
|
20
|
+
"otp": "123456",
|
|
21
|
+
"otp_confidence": 0.95,
|
|
22
|
+
"magic_link": None,
|
|
23
|
+
"links": [],
|
|
24
|
+
"detected_otp": "123456",
|
|
25
|
+
"envelope_from": "no-reply@acme.test",
|
|
26
|
+
"envelope_recipient": "user@in.inboxpipe.net",
|
|
27
|
+
"text": "Code: 123456",
|
|
28
|
+
"html": "<p>Code: 123456</p>",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
LINK_MESSAGE = {
|
|
32
|
+
**OTP_MESSAGE,
|
|
33
|
+
"id": "msg_456",
|
|
34
|
+
"otp": None,
|
|
35
|
+
"otp_confidence": None,
|
|
36
|
+
"magic_link": "https://example.test/go?token=abc",
|
|
37
|
+
"links": ["https://example.test/go?token=abc"],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FakeTransport:
|
|
42
|
+
"""A scripted urllib transport recorded by the SDK."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, responses):
|
|
45
|
+
self.responses = list(responses) # list of (status, headers, payload)
|
|
46
|
+
self.calls = [] # list of (method, url) in call order
|
|
47
|
+
|
|
48
|
+
def __call__(self, request, timeout=None):
|
|
49
|
+
import io
|
|
50
|
+
from urllib.error import HTTPError
|
|
51
|
+
|
|
52
|
+
url = request.full_url
|
|
53
|
+
self.calls.append((request.get_method(), url))
|
|
54
|
+
status, headers, payload = self.responses.pop(0)
|
|
55
|
+
body = json.dumps(payload).encode("utf-8") if payload is not None else b""
|
|
56
|
+
|
|
57
|
+
class _Resp:
|
|
58
|
+
def __init__(self, status, headers, body):
|
|
59
|
+
self.status = status
|
|
60
|
+
self.headers = headers
|
|
61
|
+
self._body = body
|
|
62
|
+
|
|
63
|
+
def getcode(self):
|
|
64
|
+
return self.status
|
|
65
|
+
|
|
66
|
+
def read(self):
|
|
67
|
+
return self._body
|
|
68
|
+
|
|
69
|
+
def __enter__(self):
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def __exit__(self, *exc_info):
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
if status >= 400:
|
|
76
|
+
raise HTTPError(url, status, "http error", headers, io.BytesIO(body))
|
|
77
|
+
return _Resp(status, headers, body)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def make_client(responses, **kwargs):
|
|
81
|
+
transport = FakeTransport(responses)
|
|
82
|
+
client = Client("ms_live_test1234567890", base_url="https://example.test/api/v1", **kwargs)
|
|
83
|
+
return client, transport
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.fixture
|
|
87
|
+
def client_factory(monkeypatch):
|
|
88
|
+
def build(responses, **kwargs):
|
|
89
|
+
client, transport = make_client(responses, **kwargs)
|
|
90
|
+
return client, transport
|
|
91
|
+
|
|
92
|
+
return build
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _monkeypatch_transport(monkeypatch, client, transport):
|
|
96
|
+
monkeypatch.setattr("urllib.request.urlopen", transport)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_wait_for_otp_returns_on_first_200(monkeypatch):
|
|
100
|
+
client, transport = make_client([(200, {}, {"data": OTP_MESSAGE})])
|
|
101
|
+
_monkeypatch_transport(monkeypatch, client, transport)
|
|
102
|
+
|
|
103
|
+
result = client.wait_for_otp("inbox_abc", timeout=60)
|
|
104
|
+
|
|
105
|
+
assert isinstance(result, WaitResult)
|
|
106
|
+
assert result.otp == "123456"
|
|
107
|
+
assert result.confidence == 0.95
|
|
108
|
+
assert str(result) == "123456"
|
|
109
|
+
assert len(transport.calls) == 1
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_wait_keeps_polling_through_204s_then_returns(monkeypatch):
|
|
113
|
+
responses = [
|
|
114
|
+
(204, {}, None),
|
|
115
|
+
(204, {}, None),
|
|
116
|
+
(200, {}, {"data": OTP_MESSAGE}),
|
|
117
|
+
]
|
|
118
|
+
client, transport = make_client(responses)
|
|
119
|
+
_monkeypatch_transport(monkeypatch, client, transport)
|
|
120
|
+
|
|
121
|
+
result = client.wait_for_otp("inbox_abc", timeout=60)
|
|
122
|
+
|
|
123
|
+
assert result.otp == "123456"
|
|
124
|
+
assert len(transport.calls) == 3
|
|
125
|
+
# server-side timeout stays clamped to <= 25s on every call
|
|
126
|
+
for _, url in transport.calls:
|
|
127
|
+
assert "timeout=" in url
|
|
128
|
+
assert "require=otp" in url
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_wait_respects_overall_timeout_raises_wait_timeout(monkeypatch):
|
|
132
|
+
# A 204 means "the server blocked for `timeout` seconds and found nothing".
|
|
133
|
+
# Model that with a controllable clock: each 204 advances time by the
|
|
134
|
+
# server-side timeout requested, so the overall client deadline (60s) is
|
|
135
|
+
# eventually exhausted across multiple 25s-clamped calls.
|
|
136
|
+
import urllib.parse
|
|
137
|
+
|
|
138
|
+
clock = [0.0]
|
|
139
|
+
calls = []
|
|
140
|
+
|
|
141
|
+
def fake_open(request, timeout=None):
|
|
142
|
+
url = request.full_url
|
|
143
|
+
calls.append(url)
|
|
144
|
+
qs = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)
|
|
145
|
+
server_timeout = float(qs["timeout"][0])
|
|
146
|
+
clock[0] += server_timeout # the server blocked this long
|
|
147
|
+
|
|
148
|
+
class _Resp:
|
|
149
|
+
def getcode(self):
|
|
150
|
+
return 204
|
|
151
|
+
|
|
152
|
+
def read(self):
|
|
153
|
+
return b""
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def headers(self):
|
|
157
|
+
return {}
|
|
158
|
+
|
|
159
|
+
def __enter__(self):
|
|
160
|
+
return self
|
|
161
|
+
|
|
162
|
+
def __exit__(self, *exc_info):
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
return _Resp()
|
|
166
|
+
|
|
167
|
+
monkeypatch.setattr("urllib.request.urlopen", fake_open)
|
|
168
|
+
monkeypatch.setattr(mailsocket.client, "_monotonic", lambda: clock[0])
|
|
169
|
+
monkeypatch.setattr(mailsocket.client, "_sleep", lambda s: clock.__setitem__(0, clock[0] + s))
|
|
170
|
+
|
|
171
|
+
client = Client("ms_live_test1234567890", base_url="https://example.test/api/v1")
|
|
172
|
+
|
|
173
|
+
with pytest.raises(WaitTimeout):
|
|
174
|
+
client.wait_for_otp("inbox_abc", timeout=60)
|
|
175
|
+
|
|
176
|
+
# Server-side timeout was clamped to 25 on every call but the last.
|
|
177
|
+
requested = [
|
|
178
|
+
float(urllib.parse.parse_qs(urllib.parse.urlsplit(u).query)["timeout"][0]) for u in calls
|
|
179
|
+
]
|
|
180
|
+
assert all(t <= 25 for t in requested)
|
|
181
|
+
assert requested[-1] <= 25
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_wait_429_parses_subcode_and_retries(monkeypatch):
|
|
185
|
+
responses = [
|
|
186
|
+
(
|
|
187
|
+
429,
|
|
188
|
+
{"Retry-After": "0"},
|
|
189
|
+
{"error": {"code": "too_many_wait_requests", "message": "Too many", "fields": {}}},
|
|
190
|
+
),
|
|
191
|
+
(200, {}, {"data": OTP_MESSAGE}),
|
|
192
|
+
]
|
|
193
|
+
client, transport = make_client(responses)
|
|
194
|
+
_monkeypatch_transport(monkeypatch, client, transport)
|
|
195
|
+
|
|
196
|
+
result = client.wait_for_otp("inbox_abc", timeout=60)
|
|
197
|
+
|
|
198
|
+
assert result.otp == "123456"
|
|
199
|
+
assert len(transport.calls) == 2
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_wait_429_raises_rate_limited_with_subcode_on_non_wait_endpoint(monkeypatch):
|
|
203
|
+
responses = [
|
|
204
|
+
(
|
|
205
|
+
429,
|
|
206
|
+
{"Retry-After": "7"},
|
|
207
|
+
{"error": {"code": "rate_limited", "message": "Rate limit exceeded.", "fields": {}}},
|
|
208
|
+
),
|
|
209
|
+
]
|
|
210
|
+
client, transport = make_client(responses)
|
|
211
|
+
_monkeypatch_transport(monkeypatch, client, transport)
|
|
212
|
+
|
|
213
|
+
with pytest.raises(RateLimited) as exc_info:
|
|
214
|
+
client.list_inboxes()
|
|
215
|
+
|
|
216
|
+
assert exc_info.value.subcode == "rate_limited"
|
|
217
|
+
assert exc_info.value.retry_after == 7.0
|
|
218
|
+
assert exc_info.value.status == 429
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_401_raises_auth_error(monkeypatch):
|
|
222
|
+
responses = [
|
|
223
|
+
(401, {}, {"error": {"code": "authentication_required", "message": "Auth required.", "fields": {}}})
|
|
224
|
+
]
|
|
225
|
+
client, transport = make_client(responses)
|
|
226
|
+
_monkeypatch_transport(monkeypatch, client, transport)
|
|
227
|
+
|
|
228
|
+
with pytest.raises(AuthError) as exc_info:
|
|
229
|
+
client.list_inboxes()
|
|
230
|
+
|
|
231
|
+
assert exc_info.value.code == "authentication_required"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_404_raises_not_found(monkeypatch):
|
|
235
|
+
responses = [
|
|
236
|
+
(404, {}, {"error": {"code": "not_found", "message": "Resource not found.", "fields": {}}})
|
|
237
|
+
]
|
|
238
|
+
client, transport = make_client(responses)
|
|
239
|
+
_monkeypatch_transport(monkeypatch, client, transport)
|
|
240
|
+
|
|
241
|
+
with pytest.raises(NotFound):
|
|
242
|
+
client.get_inbox("inbox_missing")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_wait_404_raises_not_found_immediately(monkeypatch):
|
|
246
|
+
responses = [
|
|
247
|
+
(404, {}, {"error": {"code": "not_found", "message": "Resource not found.", "fields": {}}})
|
|
248
|
+
]
|
|
249
|
+
client, transport = make_client(responses)
|
|
250
|
+
_monkeypatch_transport(monkeypatch, client, transport)
|
|
251
|
+
|
|
252
|
+
with pytest.raises(NotFound):
|
|
253
|
+
client.wait_for_otp("inbox_missing", timeout=60)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_list_messages_maps_filter_params(monkeypatch):
|
|
257
|
+
responses = [(200, {}, {"data": [OTP_MESSAGE], "pagination": {"next_cursor": None, "has_more": False}})]
|
|
258
|
+
client, transport = make_client(responses)
|
|
259
|
+
_monkeypatch_transport(monkeypatch, client, transport)
|
|
260
|
+
|
|
261
|
+
page = client.list_messages("inbox_abc", has_otp=True, subject_contains="code", sender="acme.test")
|
|
262
|
+
|
|
263
|
+
assert page.has_more is False
|
|
264
|
+
assert page.next_cursor is None
|
|
265
|
+
assert len(page) == 1
|
|
266
|
+
assert page[0]["id"] == "msg_123"
|
|
267
|
+
|
|
268
|
+
url = transport.calls[0][1]
|
|
269
|
+
assert "has_otp=true" in url
|
|
270
|
+
assert "subject_contains=code" in url
|
|
271
|
+
assert "from=acme.test" in url # sender is mapped to the API's `from` param
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_from_json_key_handled_on_message_dict(monkeypatch):
|
|
275
|
+
responses = [(200, {}, {"data": OTP_MESSAGE})]
|
|
276
|
+
client, transport = make_client(responses)
|
|
277
|
+
_monkeypatch_transport(monkeypatch, client, transport)
|
|
278
|
+
|
|
279
|
+
msg = client.get_message("msg_123")
|
|
280
|
+
|
|
281
|
+
assert msg["from"] == "Acme <no-reply@acme.test>"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_wait_for_link_returns_link(monkeypatch):
|
|
285
|
+
responses = [(200, {}, {"data": LINK_MESSAGE})]
|
|
286
|
+
client, transport = make_client(responses)
|
|
287
|
+
_monkeypatch_transport(monkeypatch, client, transport)
|
|
288
|
+
|
|
289
|
+
result = client.wait_for_link("inbox_abc", timeout=60)
|
|
290
|
+
|
|
291
|
+
assert result.magic_link == "https://example.test/go?token=abc"
|
|
292
|
+
assert result.link == result.magic_link
|
|
293
|
+
assert result.otp is None
|
|
294
|
+
_, url = transport.calls[0]
|
|
295
|
+
assert "require=link" in url
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_wait_any_returns_otp_or_link(monkeypatch):
|
|
299
|
+
responses = [(200, {}, {"data": OTP_MESSAGE})]
|
|
300
|
+
client, transport = make_client(responses)
|
|
301
|
+
_monkeypatch_transport(monkeypatch, client, transport)
|
|
302
|
+
|
|
303
|
+
result = client.wait("inbox_abc", timeout=60)
|
|
304
|
+
|
|
305
|
+
assert result.otp == "123456"
|
|
306
|
+
_, url = transport.calls[0]
|
|
307
|
+
assert "require=any" in url
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_create_inbox_sends_label_and_parses_data(monkeypatch):
|
|
311
|
+
inbox = {
|
|
312
|
+
"id": "inbox_new",
|
|
313
|
+
"label": "signup",
|
|
314
|
+
"address": "x@in.inboxpipe.net",
|
|
315
|
+
"is_enabled": True,
|
|
316
|
+
"created_at": "2026-09-23T12:00:00Z",
|
|
317
|
+
"updated_at": "2026-09-23T12:00:00Z",
|
|
318
|
+
}
|
|
319
|
+
responses = [(201, {}, {"data": inbox})]
|
|
320
|
+
client, transport = make_client(responses)
|
|
321
|
+
_monkeypatch_transport(monkeypatch, client, transport)
|
|
322
|
+
|
|
323
|
+
result = client.create_inbox(label="signup")
|
|
324
|
+
|
|
325
|
+
assert result["id"] == "inbox_new"
|
|
326
|
+
method, url = transport.calls[0]
|
|
327
|
+
assert method == "POST"
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_create_inbox_without_label_sends_empty_body(monkeypatch):
|
|
331
|
+
inbox = {
|
|
332
|
+
"id": "inbox_new",
|
|
333
|
+
"label": "",
|
|
334
|
+
"address": "x@in.inboxpipe.net",
|
|
335
|
+
"is_enabled": True,
|
|
336
|
+
"created_at": "2026-09-23T12:00:00Z",
|
|
337
|
+
"updated_at": "2026-09-23T12:00:00Z",
|
|
338
|
+
}
|
|
339
|
+
responses = [(201, {}, {"data": inbox})]
|
|
340
|
+
client, transport = make_client(responses)
|
|
341
|
+
_monkeypatch_transport(monkeypatch, client, transport)
|
|
342
|
+
|
|
343
|
+
client.create_inbox()
|
|
344
|
+
|
|
345
|
+
assert len(transport.calls) == 1
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def test_error_types_are_mailsocket_subclasses():
|
|
349
|
+
assert issubclass(AuthError, MailsocketError)
|
|
350
|
+
assert issubclass(NotFound, MailsocketError)
|
|
351
|
+
assert issubclass(RateLimited, MailsocketError)
|
|
352
|
+
assert issubclass(WaitTimeout, MailsocketError)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def test_version_exported():
|
|
356
|
+
assert hasattr(mailsocket, "__version__")
|