mailsocket 0.1.1__tar.gz → 0.1.3__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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mailsocket
3
- Version: 0.1.1
3
+ Version: 0.1.3
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
@@ -112,9 +112,15 @@ Semantics:
112
112
  ## Develop
113
113
 
114
114
  ```bash
115
- cd sdks/python
115
+ cd python
116
116
  python -m pytest -q
117
117
  ```
118
118
 
119
- Tests are fully offline — they monkeypatch `urllib.request.urlopen` with a
120
- scripted responder, so nothing touches the live API.
119
+ Tests make no external or live-API requests. Most monkeypatch
120
+ `urllib.request.urlopen` with a scripted responder, and a few start a real local
121
+ `http.server` on `127.0.0.1` to check actual request behaviour, e.g. that an
122
+ invalid id never reaches the network.
123
+
124
+ ## Changelog
125
+
126
+ - **0.1.3** — `_seg` now rejects an empty id or a bare `.`/`..` id with a `MailsocketError` (`code="invalid_id"`) before any request is made, matching the TypeScript SDK's contract.
@@ -86,9 +86,15 @@ Semantics:
86
86
  ## Develop
87
87
 
88
88
  ```bash
89
- cd sdks/python
89
+ cd python
90
90
  python -m pytest -q
91
91
  ```
92
92
 
93
- Tests are fully offline — they monkeypatch `urllib.request.urlopen` with a
94
- scripted responder, so nothing touches the live API.
93
+ Tests make no external or live-API requests. Most monkeypatch
94
+ `urllib.request.urlopen` with a scripted responder, and a few start a real local
95
+ `http.server` on `127.0.0.1` to check actual request behaviour, e.g. that an
96
+ invalid id never reaches the network.
97
+
98
+ ## Changelog
99
+
100
+ - **0.1.3** — `_seg` now rejects an empty id or a bare `.`/`..` id with a `MailsocketError` (`code="invalid_id"`) before any request is made, matching the TypeScript SDK's contract.
@@ -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.1"
6
+ __version__ = "0.1.3"
7
7
 
8
8
  __all__ = [
9
9
  "Client",
@@ -29,6 +29,24 @@ 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
+ An empty id and the bare dot segments ``.`` / ``..`` are rejected with a
36
+ :class:`MailsocketError` before any request is made (same contract as the
37
+ TypeScript SDK's ``seg``): urllib's request machinery would otherwise let
38
+ them collapse into a different path than the caller intended. No real id
39
+ is ever one of these.
40
+ """
41
+ raw = str(value)
42
+ if raw in ("", ".", ".."):
43
+ raise MailsocketError(
44
+ f"Invalid id {raw!r}: ids must be non-empty and not \".\" or \"..\".",
45
+ code="invalid_id",
46
+ )
47
+ return urllib.parse.quote(raw, safe="")
48
+
49
+
32
50
  class Page:
33
51
  """A page of results plus its pagination metadata.
34
52
 
@@ -94,12 +112,32 @@ class Client:
94
112
  base_url: str = DEFAULT_BASE_URL,
95
113
  *,
96
114
  request_timeout: float = 30.0,
115
+ extra_headers: dict[str, str] | None = None,
116
+ retry_wait_on_429: bool = True,
97
117
  ):
118
+ """Create a client.
119
+
120
+ ``extra_headers`` (optional) are sent on every request, e.g. a fixed
121
+ ``Host`` when calling the API through an internal address. They can
122
+ never override ``Authorization``; that header always carries
123
+ ``api_key``.
124
+
125
+ ``retry_wait_on_429`` (default True): the ``wait*`` helpers sleep for
126
+ ``Retry-After`` and retry on HTTP 429 until their deadline. Pass False
127
+ to raise :class:`RateLimited` immediately instead (used by the shared
128
+ remote MCP server so a rate-limited caller doesn't hold a worker slot).
129
+ """
98
130
  if not api_key:
99
131
  raise ValueError("api_key is required")
100
132
  self._api_key = api_key
101
133
  self._base_url = base_url.rstrip("/")
102
134
  self._request_timeout = request_timeout
135
+ self._retry_wait_on_429 = retry_wait_on_429
136
+ self._extra_headers = {
137
+ str(name): str(value)
138
+ for name, value in (extra_headers or {}).items()
139
+ if str(name).lower() != "authorization"
140
+ }
103
141
 
104
142
  # -- public API -----------------------------------------------------------
105
143
 
@@ -113,11 +151,11 @@ class Client:
113
151
  return Page(payload["data"], payload.get("pagination"))
114
152
 
115
153
  def get_inbox(self, inbox_id: str) -> dict:
116
- payload = self._request("GET", f"/inboxes/{inbox_id}")
154
+ payload = self._request("GET", f"/inboxes/{_seg(inbox_id)}")
117
155
  return payload["data"]
118
156
 
119
157
  def delete_inbox(self, inbox_id: str) -> None:
120
- self._request("DELETE", f"/inboxes/{inbox_id}")
158
+ self._request("DELETE", f"/inboxes/{_seg(inbox_id)}")
121
159
 
122
160
  def list_messages(
123
161
  self,
@@ -135,15 +173,15 @@ class Client:
135
173
  params["subject_contains"] = subject_contains
136
174
  if sender:
137
175
  params["from"] = sender # the API names this filter ``from``
138
- payload = self._request("GET", f"/inboxes/{inbox_id}/messages", params=params)
176
+ payload = self._request("GET", f"/inboxes/{_seg(inbox_id)}/messages", params=params)
139
177
  return Page(payload["data"], payload.get("pagination"))
140
178
 
141
179
  def get_latest(self, inbox_id: str) -> dict:
142
- payload = self._request("GET", f"/inboxes/{inbox_id}/messages/latest")
180
+ payload = self._request("GET", f"/inboxes/{_seg(inbox_id)}/messages/latest")
143
181
  return payload["data"]
144
182
 
145
183
  def get_message(self, message_id: str) -> dict:
146
- payload = self._request("GET", f"/messages/{message_id}")
184
+ payload = self._request("GET", f"/messages/{_seg(message_id)}")
147
185
  return payload["data"]
148
186
 
149
187
  # -- the moat -------------------------------------------------------------
@@ -215,7 +253,7 @@ class Client:
215
253
 
216
254
  status, headers, body = self._request_http(
217
255
  "GET",
218
- f"/inboxes/{inbox_id}/messages/wait",
256
+ f"/inboxes/{_seg(inbox_id)}/messages/wait",
219
257
  params=params,
220
258
  socket_timeout=server_timeout + WAIT_SOCKET_BUFFER,
221
259
  )
@@ -225,6 +263,8 @@ class Client:
225
263
  if status == 204:
226
264
  continue
227
265
  if status == 429:
266
+ if not self._retry_wait_on_429:
267
+ self._raise_for_status(status, headers, payload)
228
268
  retry_after = self._retry_after(headers)
229
269
  sleep_for = retry_after if retry_after is not None else DEFAULT_RETRY_AFTER
230
270
  if _monotonic() + sleep_for > deadline:
@@ -247,10 +287,12 @@ class Client:
247
287
  if params:
248
288
  url += "?" + urllib.parse.urlencode(params, doseq=True)
249
289
  headers = {
250
- "Authorization": f"Bearer {self._api_key}",
251
290
  "Accept": "application/json",
252
- "User-Agent": "mailsocket-python/0.1.1",
291
+ "User-Agent": "mailsocket-python/0.1.3",
253
292
  }
293
+ headers.update(self._extra_headers)
294
+ # Set last so nothing in extra_headers can ever replace it.
295
+ headers["Authorization"] = f"Bearer {self._api_key}"
254
296
  data = None
255
297
  if json_body is not None:
256
298
  data = json.dumps(json_body).encode("utf-8")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mailsocket
3
- Version: 0.1.1
3
+ Version: 0.1.3
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
@@ -112,9 +112,15 @@ Semantics:
112
112
  ## Develop
113
113
 
114
114
  ```bash
115
- cd sdks/python
115
+ cd python
116
116
  python -m pytest -q
117
117
  ```
118
118
 
119
- Tests are fully offline — they monkeypatch `urllib.request.urlopen` with a
120
- scripted responder, so nothing touches the live API.
119
+ Tests make no external or live-API requests. Most monkeypatch
120
+ `urllib.request.urlopen` with a scripted responder, and a few start a real local
121
+ `http.server` on `127.0.0.1` to check actual request behaviour, e.g. that an
122
+ invalid id never reaches the network.
123
+
124
+ ## Changelog
125
+
126
+ - **0.1.3** — `_seg` now rejects an empty id or a bare `.`/`..` id with a `MailsocketError` (`code="invalid_id"`) before any request is made, matching the TypeScript SDK's contract.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mailsocket"
7
- version = "0.1.1"
7
+ version = "0.1.3"
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"
@@ -199,6 +199,89 @@ 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
+ ("a/b", "a%2Fb"),
207
+ ("sp ce&%", "sp%20ce%26%25"),
208
+ ]
209
+
210
+
211
+ @pytest.mark.parametrize("raw, encoded", HOSTILE_IDS)
212
+ def test_ids_are_quoted_as_one_path_segment_everywhere(monkeypatch, raw, encoded):
213
+ """opus P3-2: every id interpolated into a URL path is quote(..., safe="")."""
214
+ import urllib.parse
215
+
216
+ page = {"data": [], "pagination": {}}
217
+ responses = [
218
+ (200, {}, {"data": {"id": "x"}}), # get_inbox
219
+ (204, {}, None), # delete_inbox
220
+ (200, {}, page), # list_messages
221
+ (200, {}, {"data": {"id": "x"}}), # get_latest
222
+ (200, {}, {"data": {"id": "x"}}), # get_message
223
+ (200, {}, {"data": OTP_MESSAGE}), # wait_for_otp
224
+ (200, {}, {"data": LINK_MESSAGE}), # wait_for_link
225
+ (200, {}, {"data": OTP_MESSAGE}), # wait
226
+ ]
227
+ client, transport = make_client(responses)
228
+ _monkeypatch_transport(monkeypatch, client, transport)
229
+
230
+ client.get_inbox(raw)
231
+ client.delete_inbox(raw)
232
+ client.list_messages(raw, has_otp=True)
233
+ client.get_latest(raw)
234
+ client.get_message(raw)
235
+ client.wait_for_otp(raw, timeout=5)
236
+ client.wait_for_link(raw, timeout=5)
237
+ client.wait(raw, timeout=5)
238
+
239
+ expected_paths = [
240
+ f"/api/v1/inboxes/{encoded}",
241
+ f"/api/v1/inboxes/{encoded}",
242
+ f"/api/v1/inboxes/{encoded}/messages",
243
+ f"/api/v1/inboxes/{encoded}/messages/latest",
244
+ f"/api/v1/messages/{encoded}",
245
+ f"/api/v1/inboxes/{encoded}/messages/wait",
246
+ f"/api/v1/inboxes/{encoded}/messages/wait",
247
+ f"/api/v1/inboxes/{encoded}/messages/wait",
248
+ ]
249
+ assert len(transport.calls) == len(expected_paths)
250
+ for (_, url), expected in zip(transport.calls, expected_paths):
251
+ parts = urllib.parse.urlsplit(url)
252
+ assert parts.netloc == "example.test"
253
+ assert parts.path == expected, url
254
+ assert parts.fragment == ""
255
+ # the id never leaks into the query string
256
+ assert "x=1" not in parts.query and "frag" not in parts.query
257
+ # a normal id is unchanged
258
+ assert mailsocket.client._seg("inbox_abc123") == "inbox_abc123"
259
+
260
+
261
+ def test_wait_429_raises_immediately_when_retry_disabled(monkeypatch):
262
+ """retry_wait_on_429=False (remote MCP): no sleep, one call, RateLimited."""
263
+ slept = []
264
+ monkeypatch.setattr(mailsocket.client, "_sleep", lambda s: slept.append(s))
265
+ responses = [
266
+ (
267
+ 429,
268
+ {"Retry-After": "2"},
269
+ {"error": {"code": "too_many_wait_requests", "message": "Too many", "fields": {}}},
270
+ ),
271
+ (200, {}, {"data": OTP_MESSAGE}),
272
+ ]
273
+ client, transport = make_client(responses, retry_wait_on_429=False)
274
+ _monkeypatch_transport(monkeypatch, client, transport)
275
+
276
+ with pytest.raises(RateLimited) as exc_info:
277
+ client.wait_for_otp("inbox_abc", timeout=60)
278
+
279
+ assert exc_info.value.subcode == "too_many_wait_requests"
280
+ assert exc_info.value.retry_after == 2.0
281
+ assert len(transport.calls) == 1
282
+ assert slept == []
283
+
284
+
202
285
  def test_wait_429_raises_rate_limited_with_subcode_on_non_wait_endpoint(monkeypatch):
203
286
  responses = [
204
287
  (
@@ -352,18 +435,88 @@ def test_error_types_are_mailsocket_subclasses():
352
435
  assert issubclass(WaitTimeout, MailsocketError)
353
436
 
354
437
 
438
+ # -- F2: _seg rejects "" / "." / ".." before any request (parity with TS) -----
439
+
440
+
441
+ def test_seg_rejects_empty_and_dot_segments():
442
+ for raw in ("", ".", ".."):
443
+ with pytest.raises(MailsocketError) as exc_info:
444
+ mailsocket.client._seg(raw)
445
+ assert exc_info.value.code == "invalid_id"
446
+
447
+
448
+ INVALID_IDS = ["", ".", ".."]
449
+
450
+
451
+ @pytest.mark.parametrize("raw", INVALID_IDS)
452
+ def test_invalid_id_rejected_before_any_request_hits_real_server(raw):
453
+ """Nothing reaches the network: a real local http.server never sees a request."""
454
+ import http.server
455
+ import threading
456
+
457
+ seen = []
458
+
459
+ class Handler(http.server.BaseHTTPRequestHandler):
460
+ def _record(self):
461
+ seen.append(self.path)
462
+ self.send_response(200)
463
+ self.send_header("Content-Type", "application/json")
464
+ self.end_headers()
465
+ self.wfile.write(json.dumps({"data": {"id": "x"}}).encode())
466
+
467
+ def do_GET(self):
468
+ self._record()
469
+
470
+ def do_DELETE(self):
471
+ self._record()
472
+
473
+ def do_POST(self):
474
+ self._record()
475
+
476
+ def log_message(self, format, *args):
477
+ pass # keep test output clean
478
+
479
+ server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
480
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
481
+ thread.start()
482
+ try:
483
+ base_url = f"http://127.0.0.1:{server.server_port}/api/v1"
484
+ client = Client("ms_live_test1234567890", base_url=base_url)
485
+
486
+ calls = [
487
+ lambda: client.get_inbox(raw),
488
+ lambda: client.delete_inbox(raw),
489
+ lambda: client.list_messages(raw),
490
+ lambda: client.get_latest(raw),
491
+ lambda: client.get_message(raw),
492
+ lambda: client.wait_for_otp(raw, timeout=5),
493
+ lambda: client.wait_for_link(raw, timeout=5),
494
+ lambda: client.wait(raw, timeout=5),
495
+ ]
496
+ for call in calls:
497
+ with pytest.raises(MailsocketError) as exc_info:
498
+ call()
499
+ assert exc_info.value.code == "invalid_id"
500
+ assert not isinstance(exc_info.value, WaitTimeout)
501
+
502
+ assert seen == [] # nothing ever reached the network
503
+ finally:
504
+ server.shutdown()
505
+ thread.join(timeout=5)
506
+
507
+
355
508
  def test_version_exported():
356
509
  assert hasattr(mailsocket, "__version__")
357
510
 
358
511
 
359
512
  def test_metadata_consistency_across_files():
360
- """0.1.1 must match everywhere: pyproject, __version__, user agent, and MCP sibling."""
513
+ """0.1.3 must match everywhere: pyproject, __version__, user agent."""
361
514
  import re
362
515
  import tomllib
363
516
  from pathlib import Path
364
517
 
365
518
  root = Path(__file__).resolve().parents[1]
366
- expected = "0.1.1"
519
+ expected = "0.1.3"
367
520
 
368
521
  pyproject = tomllib.loads((root / "pyproject.toml").read_text())
369
522
  assert pyproject["project"]["version"] == expected
@@ -373,3 +526,69 @@ def test_metadata_consistency_across_files():
373
526
  match = re.search(r'"mailsocket-python/([^"]+)"', client_src)
374
527
  assert match, "User-Agent literal not found in client.py"
375
528
  assert match.group(1) == expected
529
+
530
+
531
+ # -- extra_headers ------------------------------------------------------------
532
+
533
+
534
+ class _HeaderCapture:
535
+ def __init__(self):
536
+ self.headers = []
537
+
538
+ def __call__(self, request, timeout=None):
539
+ self.headers.append({k.lower(): v for k, v in request.header_items()})
540
+
541
+ class _Resp:
542
+ headers = {}
543
+
544
+ def getcode(self):
545
+ return 200
546
+
547
+ def read(self):
548
+ return json.dumps({"data": {"id": "inbox_1"}}).encode()
549
+
550
+ def __enter__(self):
551
+ return self
552
+
553
+ def __exit__(self, *exc_info):
554
+ return False
555
+
556
+ return _Resp()
557
+
558
+
559
+ def test_extra_headers_are_sent(monkeypatch):
560
+ capture = _HeaderCapture()
561
+ monkeypatch.setattr("urllib.request.urlopen", capture)
562
+ client = Client(
563
+ "ms_live_test1234567890",
564
+ base_url="http://web:8000/api/v1",
565
+ extra_headers={"Host": "dash.mailsocket.app", "X-Forwarded-Proto": "https"},
566
+ )
567
+ client.get_inbox("inbox_1")
568
+ sent = capture.headers[0]
569
+ assert sent["host"] == "dash.mailsocket.app"
570
+ assert sent["x-forwarded-proto"] == "https"
571
+ assert sent["authorization"] == "Bearer ms_live_test1234567890"
572
+
573
+
574
+ @pytest.mark.parametrize("name", ["Authorization", "authorization", "AUTHORIZATION"])
575
+ def test_extra_headers_cannot_override_authorization(monkeypatch, name):
576
+ capture = _HeaderCapture()
577
+ monkeypatch.setattr("urllib.request.urlopen", capture)
578
+ client = Client(
579
+ "ms_live_test1234567890",
580
+ base_url="https://example.test/api/v1",
581
+ extra_headers={name: "Bearer ms_live_ATTACKER_KEY_000"},
582
+ )
583
+ client.get_inbox("inbox_1")
584
+ auths = [v for k, v in capture.headers[0].items() if k == "authorization"]
585
+ assert auths == ["Bearer ms_live_test1234567890"]
586
+
587
+
588
+ def test_no_extra_headers_is_backwards_compatible(monkeypatch):
589
+ capture = _HeaderCapture()
590
+ monkeypatch.setattr("urllib.request.urlopen", capture)
591
+ Client("ms_live_test1234567890", base_url="https://example.test/api/v1").get_inbox("i")
592
+ sent = capture.headers[0]
593
+ assert set(sent) >= {"authorization", "accept", "user-agent"}
594
+ assert "x-forwarded-proto" not in sent
File without changes
File without changes