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.
@@ -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,3 @@
1
+
2
+ [dev]
3
+ pytest>=7
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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__")