mailsocket 0.1.0__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mailsocket
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Official Python client for the mailsocket REST API — one line to wait for an OTP.
5
5
  Author: mailsocket
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://mailsocket.app
8
8
  Project-URL: Documentation, https://dash.mailsocket.app/docs
9
+ Project-URL: Source, https://github.com/mailsocket/mailsocket-sdk/tree/main/python
10
+ Project-URL: Issues, https://github.com/mailsocket/mailsocket-sdk/issues
9
11
  Keywords: mailsocket,email,otp,inbox,magic-link
10
12
  Classifier: Intended Audience :: Developers
11
13
  Classifier: Operating System :: OS Independent
@@ -70,11 +72,21 @@ client = Client(api_key, base_url="https://dash.mailsocket.app/api/v1")
70
72
  ### The moat
71
73
 
72
74
  ```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
75
+ result = client.wait_for_otp("inbox_abc123", timeout=60, min_confidence=0.0, since=0)
76
+ result = client.wait_for_link("inbox_abc123", timeout=60, since=0)
77
+ result = client.wait("inbox_abc123", timeout=60, min_confidence=0.0, since=0) # otp OR link
76
78
  ```
77
79
 
80
+ All three return a `WaitResult` and take keyword-only arguments after
81
+ `inbox_id`. `timeout`/`min_confidence`/`since` shown above are the defaults —
82
+ call with no extra arguments for the common case.
83
+
84
+ | Method | Keyword args (all optional, keyword-only) | Returns |
85
+ | --- | --- | --- |
86
+ | `wait_for_otp(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=0` | `WaitResult` |
87
+ | `wait_for_link(inbox_id, ...)` | `timeout=60`, `since=0` | `WaitResult` |
88
+ | `wait(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=0` | `WaitResult` (otp OR link) |
89
+
78
90
  `WaitResult` exposes `.otp`, `.confidence`, `.magic_link` (and `.link` as an
79
91
  alias), plus the full `.message` dict. `str(result)` is the OTP (or the link).
80
92
 
@@ -100,7 +112,7 @@ Semantics:
100
112
  ## Develop
101
113
 
102
114
  ```bash
103
- cd sdks/python
115
+ cd python
104
116
  python -m pytest -q
105
117
  ```
106
118
 
@@ -46,11 +46,21 @@ client = Client(api_key, base_url="https://dash.mailsocket.app/api/v1")
46
46
  ### The moat
47
47
 
48
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
49
+ result = client.wait_for_otp("inbox_abc123", timeout=60, min_confidence=0.0, since=0)
50
+ result = client.wait_for_link("inbox_abc123", timeout=60, since=0)
51
+ result = client.wait("inbox_abc123", timeout=60, min_confidence=0.0, since=0) # otp OR link
52
52
  ```
53
53
 
54
+ All three return a `WaitResult` and take keyword-only arguments after
55
+ `inbox_id`. `timeout`/`min_confidence`/`since` shown above are the defaults —
56
+ call with no extra arguments for the common case.
57
+
58
+ | Method | Keyword args (all optional, keyword-only) | Returns |
59
+ | --- | --- | --- |
60
+ | `wait_for_otp(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=0` | `WaitResult` |
61
+ | `wait_for_link(inbox_id, ...)` | `timeout=60`, `since=0` | `WaitResult` |
62
+ | `wait(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=0` | `WaitResult` (otp OR link) |
63
+
54
64
  `WaitResult` exposes `.otp`, `.confidence`, `.magic_link` (and `.link` as an
55
65
  alias), plus the full `.message` dict. `str(result)` is the OTP (or the link).
56
66
 
@@ -76,7 +86,7 @@ Semantics:
76
86
  ## Develop
77
87
 
78
88
  ```bash
79
- cd sdks/python
89
+ cd python
80
90
  python -m pytest -q
81
91
  ```
82
92
 
@@ -3,7 +3,7 @@
3
3
  from .client import Client, Page, WaitResult
4
4
  from .errors import AuthError, MailsocketError, NotFound, RateLimited, WaitTimeout
5
5
 
6
- __version__ = "0.1.0"
6
+ __version__ = "0.1.2"
7
7
 
8
8
  __all__ = [
9
9
  "Client",
@@ -29,6 +29,18 @@ def _sleep(seconds: float) -> None:
29
29
  _time.sleep(seconds)
30
30
 
31
31
 
32
+ def _seg(value) -> str:
33
+ """Percent-encode an id as ONE path segment (``/``, ``?``, ``#``, ``..`` safe).
34
+
35
+ ``quote`` leaves ``.`` alone (unreserved), so a bare ``.``/``..`` segment
36
+ is additionally encoded as ``%2E`` so no proxy can treat it as traversal.
37
+ """
38
+ encoded = urllib.parse.quote(str(value), safe="")
39
+ if encoded in (".", ".."):
40
+ encoded = encoded.replace(".", "%2E")
41
+ return encoded
42
+
43
+
32
44
  class Page:
33
45
  """A page of results plus its pagination metadata.
34
46
 
@@ -94,12 +106,32 @@ class Client:
94
106
  base_url: str = DEFAULT_BASE_URL,
95
107
  *,
96
108
  request_timeout: float = 30.0,
109
+ extra_headers: dict[str, str] | None = None,
110
+ retry_wait_on_429: bool = True,
97
111
  ):
112
+ """Create a client.
113
+
114
+ ``extra_headers`` (optional) are sent on every request, e.g. a fixed
115
+ ``Host`` when calling the API through an internal address. They can
116
+ never override ``Authorization``; that header always carries
117
+ ``api_key``.
118
+
119
+ ``retry_wait_on_429`` (default True): the ``wait*`` helpers sleep for
120
+ ``Retry-After`` and retry on HTTP 429 until their deadline. Pass False
121
+ to raise :class:`RateLimited` immediately instead (used by the shared
122
+ remote MCP server so a rate-limited caller doesn't hold a worker slot).
123
+ """
98
124
  if not api_key:
99
125
  raise ValueError("api_key is required")
100
126
  self._api_key = api_key
101
127
  self._base_url = base_url.rstrip("/")
102
128
  self._request_timeout = request_timeout
129
+ self._retry_wait_on_429 = retry_wait_on_429
130
+ self._extra_headers = {
131
+ str(name): str(value)
132
+ for name, value in (extra_headers or {}).items()
133
+ if str(name).lower() != "authorization"
134
+ }
103
135
 
104
136
  # -- public API -----------------------------------------------------------
105
137
 
@@ -113,11 +145,11 @@ class Client:
113
145
  return Page(payload["data"], payload.get("pagination"))
114
146
 
115
147
  def get_inbox(self, inbox_id: str) -> dict:
116
- payload = self._request("GET", f"/inboxes/{inbox_id}")
148
+ payload = self._request("GET", f"/inboxes/{_seg(inbox_id)}")
117
149
  return payload["data"]
118
150
 
119
151
  def delete_inbox(self, inbox_id: str) -> None:
120
- self._request("DELETE", f"/inboxes/{inbox_id}")
152
+ self._request("DELETE", f"/inboxes/{_seg(inbox_id)}")
121
153
 
122
154
  def list_messages(
123
155
  self,
@@ -135,15 +167,15 @@ class Client:
135
167
  params["subject_contains"] = subject_contains
136
168
  if sender:
137
169
  params["from"] = sender # the API names this filter ``from``
138
- payload = self._request("GET", f"/inboxes/{inbox_id}/messages", params=params)
170
+ payload = self._request("GET", f"/inboxes/{_seg(inbox_id)}/messages", params=params)
139
171
  return Page(payload["data"], payload.get("pagination"))
140
172
 
141
173
  def get_latest(self, inbox_id: str) -> dict:
142
- payload = self._request("GET", f"/inboxes/{inbox_id}/messages/latest")
174
+ payload = self._request("GET", f"/inboxes/{_seg(inbox_id)}/messages/latest")
143
175
  return payload["data"]
144
176
 
145
177
  def get_message(self, message_id: str) -> dict:
146
- payload = self._request("GET", f"/messages/{message_id}")
178
+ payload = self._request("GET", f"/messages/{_seg(message_id)}")
147
179
  return payload["data"]
148
180
 
149
181
  # -- the moat -------------------------------------------------------------
@@ -215,7 +247,7 @@ class Client:
215
247
 
216
248
  status, headers, body = self._request_http(
217
249
  "GET",
218
- f"/inboxes/{inbox_id}/messages/wait",
250
+ f"/inboxes/{_seg(inbox_id)}/messages/wait",
219
251
  params=params,
220
252
  socket_timeout=server_timeout + WAIT_SOCKET_BUFFER,
221
253
  )
@@ -225,6 +257,8 @@ class Client:
225
257
  if status == 204:
226
258
  continue
227
259
  if status == 429:
260
+ if not self._retry_wait_on_429:
261
+ self._raise_for_status(status, headers, payload)
228
262
  retry_after = self._retry_after(headers)
229
263
  sleep_for = retry_after if retry_after is not None else DEFAULT_RETRY_AFTER
230
264
  if _monotonic() + sleep_for > deadline:
@@ -247,10 +281,12 @@ class Client:
247
281
  if params:
248
282
  url += "?" + urllib.parse.urlencode(params, doseq=True)
249
283
  headers = {
250
- "Authorization": f"Bearer {self._api_key}",
251
284
  "Accept": "application/json",
252
- "User-Agent": "mailsocket-python/0.1.0",
285
+ "User-Agent": "mailsocket-python/0.1.2",
253
286
  }
287
+ headers.update(self._extra_headers)
288
+ # Set last so nothing in extra_headers can ever replace it.
289
+ headers["Authorization"] = f"Bearer {self._api_key}"
254
290
  data = None
255
291
  if json_body is not None:
256
292
  data = json.dumps(json_body).encode("utf-8")
@@ -1,11 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mailsocket
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Official Python client for the mailsocket REST API — one line to wait for an OTP.
5
5
  Author: mailsocket
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://mailsocket.app
8
8
  Project-URL: Documentation, https://dash.mailsocket.app/docs
9
+ Project-URL: Source, https://github.com/mailsocket/mailsocket-sdk/tree/main/python
10
+ Project-URL: Issues, https://github.com/mailsocket/mailsocket-sdk/issues
9
11
  Keywords: mailsocket,email,otp,inbox,magic-link
10
12
  Classifier: Intended Audience :: Developers
11
13
  Classifier: Operating System :: OS Independent
@@ -70,11 +72,21 @@ client = Client(api_key, base_url="https://dash.mailsocket.app/api/v1")
70
72
  ### The moat
71
73
 
72
74
  ```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
75
+ result = client.wait_for_otp("inbox_abc123", timeout=60, min_confidence=0.0, since=0)
76
+ result = client.wait_for_link("inbox_abc123", timeout=60, since=0)
77
+ result = client.wait("inbox_abc123", timeout=60, min_confidence=0.0, since=0) # otp OR link
76
78
  ```
77
79
 
80
+ All three return a `WaitResult` and take keyword-only arguments after
81
+ `inbox_id`. `timeout`/`min_confidence`/`since` shown above are the defaults —
82
+ call with no extra arguments for the common case.
83
+
84
+ | Method | Keyword args (all optional, keyword-only) | Returns |
85
+ | --- | --- | --- |
86
+ | `wait_for_otp(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=0` | `WaitResult` |
87
+ | `wait_for_link(inbox_id, ...)` | `timeout=60`, `since=0` | `WaitResult` |
88
+ | `wait(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=0` | `WaitResult` (otp OR link) |
89
+
78
90
  `WaitResult` exposes `.otp`, `.confidence`, `.magic_link` (and `.link` as an
79
91
  alias), plus the full `.message` dict. `str(result)` is the OTP (or the link).
80
92
 
@@ -100,7 +112,7 @@ Semantics:
100
112
  ## Develop
101
113
 
102
114
  ```bash
103
- cd sdks/python
115
+ cd python
104
116
  python -m pytest -q
105
117
  ```
106
118
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mailsocket"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Official Python client for the mailsocket REST API — one line to wait for an OTP."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -30,6 +30,8 @@ dev = ["pytest>=7"]
30
30
  [project.urls]
31
31
  Homepage = "https://mailsocket.app"
32
32
  Documentation = "https://dash.mailsocket.app/docs"
33
+ Source = "https://github.com/mailsocket/mailsocket-sdk/tree/main/python"
34
+ Issues = "https://github.com/mailsocket/mailsocket-sdk/issues"
33
35
 
34
36
  [tool.setuptools]
35
37
  packages = ["mailsocket"]
@@ -199,6 +199,91 @@ def test_wait_429_parses_subcode_and_retries(monkeypatch):
199
199
  assert len(transport.calls) == 2
200
200
 
201
201
 
202
+ HOSTILE_IDS = [
203
+ ("in?x=1", "in%3Fx%3D1"),
204
+ ("in#frag", "in%23frag"),
205
+ ("../../admin", "..%2F..%2Fadmin"),
206
+ ("..", "%2E%2E"),
207
+ (".", "%2E"),
208
+ ("a/b", "a%2Fb"),
209
+ ("sp ce&%", "sp%20ce%26%25"),
210
+ ]
211
+
212
+
213
+ @pytest.mark.parametrize("raw, encoded", HOSTILE_IDS)
214
+ def test_ids_are_quoted_as_one_path_segment_everywhere(monkeypatch, raw, encoded):
215
+ """opus P3-2: every id interpolated into a URL path is quote(..., safe="")."""
216
+ import urllib.parse
217
+
218
+ page = {"data": [], "pagination": {}}
219
+ responses = [
220
+ (200, {}, {"data": {"id": "x"}}), # get_inbox
221
+ (204, {}, None), # delete_inbox
222
+ (200, {}, page), # list_messages
223
+ (200, {}, {"data": {"id": "x"}}), # get_latest
224
+ (200, {}, {"data": {"id": "x"}}), # get_message
225
+ (200, {}, {"data": OTP_MESSAGE}), # wait_for_otp
226
+ (200, {}, {"data": LINK_MESSAGE}), # wait_for_link
227
+ (200, {}, {"data": OTP_MESSAGE}), # wait
228
+ ]
229
+ client, transport = make_client(responses)
230
+ _monkeypatch_transport(monkeypatch, client, transport)
231
+
232
+ client.get_inbox(raw)
233
+ client.delete_inbox(raw)
234
+ client.list_messages(raw, has_otp=True)
235
+ client.get_latest(raw)
236
+ client.get_message(raw)
237
+ client.wait_for_otp(raw, timeout=5)
238
+ client.wait_for_link(raw, timeout=5)
239
+ client.wait(raw, timeout=5)
240
+
241
+ expected_paths = [
242
+ f"/api/v1/inboxes/{encoded}",
243
+ f"/api/v1/inboxes/{encoded}",
244
+ f"/api/v1/inboxes/{encoded}/messages",
245
+ f"/api/v1/inboxes/{encoded}/messages/latest",
246
+ f"/api/v1/messages/{encoded}",
247
+ f"/api/v1/inboxes/{encoded}/messages/wait",
248
+ f"/api/v1/inboxes/{encoded}/messages/wait",
249
+ f"/api/v1/inboxes/{encoded}/messages/wait",
250
+ ]
251
+ assert len(transport.calls) == len(expected_paths)
252
+ for (_, url), expected in zip(transport.calls, expected_paths):
253
+ parts = urllib.parse.urlsplit(url)
254
+ assert parts.netloc == "example.test"
255
+ assert parts.path == expected, url
256
+ assert parts.fragment == ""
257
+ # the id never leaks into the query string
258
+ assert "x=1" not in parts.query and "frag" not in parts.query
259
+ # a normal id is unchanged
260
+ assert mailsocket.client._seg("inbox_abc123") == "inbox_abc123"
261
+
262
+
263
+ def test_wait_429_raises_immediately_when_retry_disabled(monkeypatch):
264
+ """retry_wait_on_429=False (remote MCP): no sleep, one call, RateLimited."""
265
+ slept = []
266
+ monkeypatch.setattr(mailsocket.client, "_sleep", lambda s: slept.append(s))
267
+ responses = [
268
+ (
269
+ 429,
270
+ {"Retry-After": "2"},
271
+ {"error": {"code": "too_many_wait_requests", "message": "Too many", "fields": {}}},
272
+ ),
273
+ (200, {}, {"data": OTP_MESSAGE}),
274
+ ]
275
+ client, transport = make_client(responses, retry_wait_on_429=False)
276
+ _monkeypatch_transport(monkeypatch, client, transport)
277
+
278
+ with pytest.raises(RateLimited) as exc_info:
279
+ client.wait_for_otp("inbox_abc", timeout=60)
280
+
281
+ assert exc_info.value.subcode == "too_many_wait_requests"
282
+ assert exc_info.value.retry_after == 2.0
283
+ assert len(transport.calls) == 1
284
+ assert slept == []
285
+
286
+
202
287
  def test_wait_429_raises_rate_limited_with_subcode_on_non_wait_endpoint(monkeypatch):
203
288
  responses = [
204
289
  (
@@ -354,3 +439,88 @@ def test_error_types_are_mailsocket_subclasses():
354
439
 
355
440
  def test_version_exported():
356
441
  assert hasattr(mailsocket, "__version__")
442
+
443
+
444
+ def test_metadata_consistency_across_files():
445
+ """0.1.2 must match everywhere: pyproject, __version__, user agent."""
446
+ import re
447
+ import tomllib
448
+ from pathlib import Path
449
+
450
+ root = Path(__file__).resolve().parents[1]
451
+ expected = "0.1.2"
452
+
453
+ pyproject = tomllib.loads((root / "pyproject.toml").read_text())
454
+ assert pyproject["project"]["version"] == expected
455
+ assert mailsocket.__version__ == expected
456
+
457
+ client_src = (root / "mailsocket" / "client.py").read_text()
458
+ match = re.search(r'"mailsocket-python/([^"]+)"', client_src)
459
+ assert match, "User-Agent literal not found in client.py"
460
+ assert match.group(1) == expected
461
+
462
+
463
+ # -- extra_headers ------------------------------------------------------------
464
+
465
+
466
+ class _HeaderCapture:
467
+ def __init__(self):
468
+ self.headers = []
469
+
470
+ def __call__(self, request, timeout=None):
471
+ self.headers.append({k.lower(): v for k, v in request.header_items()})
472
+
473
+ class _Resp:
474
+ headers = {}
475
+
476
+ def getcode(self):
477
+ return 200
478
+
479
+ def read(self):
480
+ return json.dumps({"data": {"id": "inbox_1"}}).encode()
481
+
482
+ def __enter__(self):
483
+ return self
484
+
485
+ def __exit__(self, *exc_info):
486
+ return False
487
+
488
+ return _Resp()
489
+
490
+
491
+ def test_extra_headers_are_sent(monkeypatch):
492
+ capture = _HeaderCapture()
493
+ monkeypatch.setattr("urllib.request.urlopen", capture)
494
+ client = Client(
495
+ "ms_live_test1234567890",
496
+ base_url="http://web:8000/api/v1",
497
+ extra_headers={"Host": "dash.mailsocket.app", "X-Forwarded-Proto": "https"},
498
+ )
499
+ client.get_inbox("inbox_1")
500
+ sent = capture.headers[0]
501
+ assert sent["host"] == "dash.mailsocket.app"
502
+ assert sent["x-forwarded-proto"] == "https"
503
+ assert sent["authorization"] == "Bearer ms_live_test1234567890"
504
+
505
+
506
+ @pytest.mark.parametrize("name", ["Authorization", "authorization", "AUTHORIZATION"])
507
+ def test_extra_headers_cannot_override_authorization(monkeypatch, name):
508
+ capture = _HeaderCapture()
509
+ monkeypatch.setattr("urllib.request.urlopen", capture)
510
+ client = Client(
511
+ "ms_live_test1234567890",
512
+ base_url="https://example.test/api/v1",
513
+ extra_headers={name: "Bearer ms_live_ATTACKER_KEY_000"},
514
+ )
515
+ client.get_inbox("inbox_1")
516
+ auths = [v for k, v in capture.headers[0].items() if k == "authorization"]
517
+ assert auths == ["Bearer ms_live_test1234567890"]
518
+
519
+
520
+ def test_no_extra_headers_is_backwards_compatible(monkeypatch):
521
+ capture = _HeaderCapture()
522
+ monkeypatch.setattr("urllib.request.urlopen", capture)
523
+ Client("ms_live_test1234567890", base_url="https://example.test/api/v1").get_inbox("i")
524
+ sent = capture.headers[0]
525
+ assert set(sent) >= {"authorization", "accept", "user-agent"}
526
+ assert "x-forwarded-proto" not in sent
File without changes
File without changes