mailsocket 0.1.0__py3-none-any.whl
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/__init__.py +17 -0
- mailsocket/client.py +329 -0
- mailsocket/errors.py +47 -0
- mailsocket-0.1.0.dist-info/METADATA +108 -0
- mailsocket-0.1.0.dist-info/RECORD +8 -0
- mailsocket-0.1.0.dist-info/WHEEL +5 -0
- mailsocket-0.1.0.dist-info/licenses/LICENSE +21 -0
- mailsocket-0.1.0.dist-info/top_level.txt +1 -0
mailsocket/__init__.py
ADDED
|
@@ -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
|
+
]
|
mailsocket/client.py
ADDED
|
@@ -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
|
mailsocket/errors.py
ADDED
|
@@ -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,8 @@
|
|
|
1
|
+
mailsocket/__init__.py,sha256=Hl-znPJ9glD8ahsjLEiC9L2aLA4uYCy-_t6SPMyY3fU,363
|
|
2
|
+
mailsocket/client.py,sha256=YsqHzi3O1e1FHACEPLTaU4c63juthqWIsbgxO3RJ35M,11393
|
|
3
|
+
mailsocket/errors.py,sha256=2ligv0hWGL86pvaC5IjhKjzg_cFsHDO_Dib_Hw0D_S8,1305
|
|
4
|
+
mailsocket-0.1.0.dist-info/licenses/LICENSE,sha256=Eyot-wc-_WRgJlHzEP7XSrVlFqlwaDvM9BYqHpDJVY0,1067
|
|
5
|
+
mailsocket-0.1.0.dist-info/METADATA,sha256=iCIwG367jVveSHLj1NP9FBXOgIuzYYhJzNM9ASoJips,3946
|
|
6
|
+
mailsocket-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
mailsocket-0.1.0.dist-info/top_level.txt,sha256=143NFSt58oTNv2GliKaPWHer4MY5-f6BtEwCt3vnt8c,11
|
|
8
|
+
mailsocket-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
mailsocket
|