mailsocket 0.1.2__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mailsocket
3
- Version: 0.1.2
3
+ Version: 0.2.0
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
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3.10
17
17
  Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Topic :: Communications :: Email
20
+ Classifier: Typing :: Typed
20
21
  Requires-Python: >=3.9
21
22
  Description-Content-Type: text/markdown
22
23
  License-File: LICENSE
@@ -72,9 +73,9 @@ client = Client(api_key, base_url="https://dash.mailsocket.app/api/v1")
72
73
  ### The moat
73
74
 
74
75
  ```python
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
+ result = client.wait_for_otp("inbox_abc123", timeout=60, min_confidence=0.0, since=None)
77
+ result = client.wait_for_link("inbox_abc123", timeout=60, since=None)
78
+ result = client.wait("inbox_abc123", timeout=60, min_confidence=0.0, since=None) # otp OR link
78
79
  ```
79
80
 
80
81
  All three return a `WaitResult` and take keyword-only arguments after
@@ -83,9 +84,16 @@ call with no extra arguments for the common case.
83
84
 
84
85
  | Method | Keyword args (all optional, keyword-only) | Returns |
85
86
  | --- | --- | --- |
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) |
87
+ | `wait_for_otp(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=None` | `WaitResult` |
88
+ | `wait_for_link(inbox_id, ...)` | `timeout=60`, `since=None` | `WaitResult` |
89
+ | `wait(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=None` | `WaitResult` (otp OR link) |
90
+
91
+ `since=None` (the default) omits the param, so the server uses its own
92
+ default: the request start time — a code that arrives during this call is
93
+ caught, and a stale code already in a reused inbox is not. Pass `since=0` to
94
+ restore the old "any message already in the inbox" behaviour (useful right
95
+ after `create_inbox`, where there's nothing stale to match), or a message id
96
+ / timestamp to continue after a specific point.
89
97
 
90
98
  `WaitResult` exposes `.otp`, `.confidence`, `.magic_link` (and `.link` as an
91
99
  alias), plus the full `.message` dict. `str(result)` is the OTP (or the link).
@@ -116,5 +124,16 @@ cd python
116
124
  python -m pytest -q
117
125
  ```
118
126
 
119
- Tests are fully offline — they monkeypatch `urllib.request.urlopen` with a
120
- scripted responder, so nothing touches the live API.
127
+ Tests make no external or live-API requests. Most monkeypatch
128
+ `urllib.request.urlopen` with a scripted responder, and a few start a real local
129
+ `http.server` on `127.0.0.1` to check actual request behaviour, e.g. that an
130
+ invalid id never reaches the network.
131
+
132
+ ## Changelog
133
+
134
+ - **0.2.0** — BEHAVIOUR CHANGE: `wait_for_otp`/`wait_for_link`/`wait` now
135
+ default `since=None`, which omits the param so the server uses the
136
+ request start time, instead of the old `since=0` ("any message already
137
+ in the inbox"). The old default silently returned a STALE OTP/link from a
138
+ reused inbox. Pass `since=0` explicitly to keep the old behaviour.
139
+ - **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.
@@ -46,9 +46,9 @@ 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_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
49
+ result = client.wait_for_otp("inbox_abc123", timeout=60, min_confidence=0.0, since=None)
50
+ result = client.wait_for_link("inbox_abc123", timeout=60, since=None)
51
+ result = client.wait("inbox_abc123", timeout=60, min_confidence=0.0, since=None) # otp OR link
52
52
  ```
53
53
 
54
54
  All three return a `WaitResult` and take keyword-only arguments after
@@ -57,9 +57,16 @@ call with no extra arguments for the common case.
57
57
 
58
58
  | Method | Keyword args (all optional, keyword-only) | Returns |
59
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) |
60
+ | `wait_for_otp(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=None` | `WaitResult` |
61
+ | `wait_for_link(inbox_id, ...)` | `timeout=60`, `since=None` | `WaitResult` |
62
+ | `wait(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=None` | `WaitResult` (otp OR link) |
63
+
64
+ `since=None` (the default) omits the param, so the server uses its own
65
+ default: the request start time — a code that arrives during this call is
66
+ caught, and a stale code already in a reused inbox is not. Pass `since=0` to
67
+ restore the old "any message already in the inbox" behaviour (useful right
68
+ after `create_inbox`, where there's nothing stale to match), or a message id
69
+ / timestamp to continue after a specific point.
63
70
 
64
71
  `WaitResult` exposes `.otp`, `.confidence`, `.magic_link` (and `.link` as an
65
72
  alias), plus the full `.message` dict. `str(result)` is the OTP (or the link).
@@ -90,5 +97,16 @@ cd python
90
97
  python -m pytest -q
91
98
  ```
92
99
 
93
- Tests are fully offline — they monkeypatch `urllib.request.urlopen` with a
94
- scripted responder, so nothing touches the live API.
100
+ Tests make no external or live-API requests. Most monkeypatch
101
+ `urllib.request.urlopen` with a scripted responder, and a few start a real local
102
+ `http.server` on `127.0.0.1` to check actual request behaviour, e.g. that an
103
+ invalid id never reaches the network.
104
+
105
+ ## Changelog
106
+
107
+ - **0.2.0** — BEHAVIOUR CHANGE: `wait_for_otp`/`wait_for_link`/`wait` now
108
+ default `since=None`, which omits the param so the server uses the
109
+ request start time, instead of the old `since=0` ("any message already
110
+ in the inbox"). The old default silently returned a STALE OTP/link from a
111
+ reused inbox. Pass `since=0` explicitly to keep the old behaviour.
112
+ - **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.2"
6
+ __version__ = "0.2.0"
7
7
 
8
8
  __all__ = [
9
9
  "Client",
@@ -10,6 +10,7 @@ import time as _time
10
10
  import urllib.error
11
11
  import urllib.parse
12
12
  import urllib.request
13
+ from dataclasses import dataclass, field
13
14
 
14
15
  from .errors import AuthError, MailsocketError, NotFound, RateLimited, WaitTimeout
15
16
 
@@ -32,13 +33,19 @@ def _sleep(seconds: float) -> None:
32
33
  def _seg(value) -> str:
33
34
  """Percent-encode an id as ONE path segment (``/``, ``?``, ``#``, ``..`` safe).
34
35
 
35
- ``quote`` leaves ``.`` alone (unreserved), so a bare ``.``/``..`` segment
36
- is additionally encoded as ``%2E`` so no proxy can treat it as traversal.
36
+ An empty id and the bare dot segments ``.`` / ``..`` are rejected with a
37
+ :class:`MailsocketError` before any request is made (same contract as the
38
+ TypeScript SDK's ``seg``): urllib's request machinery would otherwise let
39
+ them collapse into a different path than the caller intended. No real id
40
+ is ever one of these.
37
41
  """
38
- encoded = urllib.parse.quote(str(value), safe="")
39
- if encoded in (".", ".."):
40
- encoded = encoded.replace(".", "%2E")
41
- return encoded
42
+ raw = str(value)
43
+ if raw in ("", ".", ".."):
44
+ raise MailsocketError(
45
+ f"Invalid id {raw!r}: ids must be non-empty and not \".\" or \"..\".",
46
+ code="invalid_id",
47
+ )
48
+ return urllib.parse.quote(raw, safe="")
42
49
 
43
50
 
44
51
  class Page:
@@ -73,14 +80,30 @@ class Page:
73
80
  return f"Page(data={self.data!r}, pagination={self.pagination!r})"
74
81
 
75
82
 
83
+ @dataclass(frozen=True, eq=False)
76
84
  class WaitResult:
77
- """The outcome of a ``wait_for_otp`` / ``wait_for_link`` / ``wait`` call."""
85
+ """The outcome of a ``wait_for_otp`` / ``wait_for_link`` / ``wait`` call.
86
+
87
+ #31 (trust-audit-OUT.md): a frozen dataclass, backward compatible with
88
+ the plain-class shape it replaces — same four public attributes
89
+ (``.message``, ``.otp``, ``.confidence``, ``.magic_link``), the derived
90
+ ``.link`` property, and the same ``repr()``/``str()``. Instances are now
91
+ immutable (``frozen=True``); ``eq=False`` keeps equality/hashing
92
+ identity-based exactly as the old plain class, since ``message`` is a
93
+ dict and value-equality would make ``hash()`` raise ``TypeError``.
94
+ """
95
+
96
+ message: dict
97
+ otp: str | None = field(init=False)
98
+ confidence: float | None = field(init=False)
99
+ magic_link: str | None = field(init=False)
78
100
 
79
- def __init__(self, message: dict):
80
- self.message = message
81
- self.otp = message.get("otp")
82
- self.confidence = message.get("otp_confidence")
83
- self.magic_link = message.get("magic_link")
101
+ def __post_init__(self):
102
+ # frozen=True disallows plain attribute assignment even inside
103
+ # __post_init__, hence object.__setattr__.
104
+ object.__setattr__(self, "otp", self.message.get("otp"))
105
+ object.__setattr__(self, "confidence", self.message.get("otp_confidence"))
106
+ object.__setattr__(self, "magic_link", self.message.get("magic_link"))
84
107
 
85
108
  @property
86
109
  def link(self):
@@ -186,9 +209,16 @@ class Client:
186
209
  *,
187
210
  timeout: float = 60.0,
188
211
  min_confidence: float = 0.0,
189
- since=0,
212
+ since=None,
190
213
  ) -> WaitResult:
191
- """Wait for an OTP. Returns a :class:`WaitResult` with ``.otp`` etc."""
214
+ """Wait for an OTP. Returns a :class:`WaitResult` with ``.otp`` etc.
215
+
216
+ ``since`` defaults to ``None``, which omits the param entirely so the
217
+ server's own default applies (the request start time) — a caller
218
+ only sees messages that arrive during this call, never a stale OTP
219
+ already sitting in a reused inbox. Pass ``since=0`` to restore the
220
+ old behaviour: match any message already in the inbox.
221
+ """
192
222
  return self._wait(
193
223
  inbox_id,
194
224
  require="otp",
@@ -197,8 +227,12 @@ class Client:
197
227
  since=since,
198
228
  )
199
229
 
200
- def wait_for_link(self, inbox_id: str, *, timeout: float = 60.0, since=0) -> WaitResult:
201
- """Wait for a magic link. Returns a :class:`WaitResult` with ``.link``."""
230
+ def wait_for_link(self, inbox_id: str, *, timeout: float = 60.0, since=None) -> WaitResult:
231
+ """Wait for a magic link. Returns a :class:`WaitResult` with ``.link``.
232
+
233
+ ``since`` defaults to ``None`` (omitted -> server uses the request
234
+ start time). Pass ``since=0`` to include messages already in the inbox.
235
+ """
202
236
  return self._wait(
203
237
  inbox_id, require="link", timeout=timeout, min_confidence=None, since=since
204
238
  )
@@ -209,9 +243,13 @@ class Client:
209
243
  *,
210
244
  timeout: float = 60.0,
211
245
  min_confidence: float = 0.0,
212
- since=0,
246
+ since=None,
213
247
  ) -> WaitResult:
214
- """Wait for either an OTP or a magic link."""
248
+ """Wait for either an OTP or a magic link.
249
+
250
+ ``since`` defaults to ``None`` (omitted -> server uses the request
251
+ start time). Pass ``since=0`` to include messages already in the inbox.
252
+ """
215
253
  return self._wait(
216
254
  inbox_id,
217
255
  require="any",
@@ -282,7 +320,7 @@ class Client:
282
320
  url += "?" + urllib.parse.urlencode(params, doseq=True)
283
321
  headers = {
284
322
  "Accept": "application/json",
285
- "User-Agent": "mailsocket-python/0.1.2",
323
+ "User-Agent": "mailsocket-python/0.2.0",
286
324
  }
287
325
  headers.update(self._extra_headers)
288
326
  # Set last so nothing in extra_headers can ever replace it.
File without changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mailsocket
3
- Version: 0.1.2
3
+ Version: 0.2.0
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
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3.10
17
17
  Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Topic :: Communications :: Email
20
+ Classifier: Typing :: Typed
20
21
  Requires-Python: >=3.9
21
22
  Description-Content-Type: text/markdown
22
23
  License-File: LICENSE
@@ -72,9 +73,9 @@ client = Client(api_key, base_url="https://dash.mailsocket.app/api/v1")
72
73
  ### The moat
73
74
 
74
75
  ```python
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
+ result = client.wait_for_otp("inbox_abc123", timeout=60, min_confidence=0.0, since=None)
77
+ result = client.wait_for_link("inbox_abc123", timeout=60, since=None)
78
+ result = client.wait("inbox_abc123", timeout=60, min_confidence=0.0, since=None) # otp OR link
78
79
  ```
79
80
 
80
81
  All three return a `WaitResult` and take keyword-only arguments after
@@ -83,9 +84,16 @@ call with no extra arguments for the common case.
83
84
 
84
85
  | Method | Keyword args (all optional, keyword-only) | Returns |
85
86
  | --- | --- | --- |
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) |
87
+ | `wait_for_otp(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=None` | `WaitResult` |
88
+ | `wait_for_link(inbox_id, ...)` | `timeout=60`, `since=None` | `WaitResult` |
89
+ | `wait(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=None` | `WaitResult` (otp OR link) |
90
+
91
+ `since=None` (the default) omits the param, so the server uses its own
92
+ default: the request start time — a code that arrives during this call is
93
+ caught, and a stale code already in a reused inbox is not. Pass `since=0` to
94
+ restore the old "any message already in the inbox" behaviour (useful right
95
+ after `create_inbox`, where there's nothing stale to match), or a message id
96
+ / timestamp to continue after a specific point.
89
97
 
90
98
  `WaitResult` exposes `.otp`, `.confidence`, `.magic_link` (and `.link` as an
91
99
  alias), plus the full `.message` dict. `str(result)` is the OTP (or the link).
@@ -116,5 +124,16 @@ cd python
116
124
  python -m pytest -q
117
125
  ```
118
126
 
119
- Tests are fully offline — they monkeypatch `urllib.request.urlopen` with a
120
- scripted responder, so nothing touches the live API.
127
+ Tests make no external or live-API requests. Most monkeypatch
128
+ `urllib.request.urlopen` with a scripted responder, and a few start a real local
129
+ `http.server` on `127.0.0.1` to check actual request behaviour, e.g. that an
130
+ invalid id never reaches the network.
131
+
132
+ ## Changelog
133
+
134
+ - **0.2.0** — BEHAVIOUR CHANGE: `wait_for_otp`/`wait_for_link`/`wait` now
135
+ default `since=None`, which omits the param so the server uses the
136
+ request start time, instead of the old `since=0` ("any message already
137
+ in the inbox"). The old default silently returned a STALE OTP/link from a
138
+ reused inbox. Pass `since=0` explicitly to keep the old behaviour.
139
+ - **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,6 +4,7 @@ pyproject.toml
4
4
  mailsocket/__init__.py
5
5
  mailsocket/client.py
6
6
  mailsocket/errors.py
7
+ mailsocket/py.typed
7
8
  mailsocket.egg-info/PKG-INFO
8
9
  mailsocket.egg-info/SOURCES.txt
9
10
  mailsocket.egg-info/dependency_links.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mailsocket"
7
- version = "0.1.2"
7
+ version = "0.2.0"
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"
@@ -21,6 +21,7 @@ classifiers = [
21
21
  "Programming Language :: Python :: 3.11",
22
22
  "Programming Language :: Python :: 3.12",
23
23
  "Topic :: Communications :: Email",
24
+ "Typing :: Typed",
24
25
  ]
25
26
  dependencies = []
26
27
 
@@ -36,6 +37,9 @@ Issues = "https://github.com/mailsocket/mailsocket-sdk/issues"
36
37
  [tool.setuptools]
37
38
  packages = ["mailsocket"]
38
39
 
40
+ [tool.setuptools.package-data]
41
+ mailsocket = ["py.typed"]
42
+
39
43
  [tool.pytest.ini_options]
40
44
  testpaths = ["tests"]
41
45
  pythonpath = ["."]
@@ -1,6 +1,7 @@
1
1
  """Offline unit tests for the mailsocket Python SDK (mocked transport, no network)."""
2
2
 
3
3
  import json
4
+ import urllib.parse
4
5
 
5
6
  import pytest
6
7
 
@@ -109,6 +110,70 @@ def test_wait_for_otp_returns_on_first_200(monkeypatch):
109
110
  assert len(transport.calls) == 1
110
111
 
111
112
 
113
+ def test_wait_result_equality_and_hash_are_identity_based():
114
+ """R2 FIX 3 (GPT review): frozen=True makes WaitResult immutable, but
115
+ equality/hash must stay identity-based exactly as the old plain class —
116
+ value-equality would make hash() raise TypeError because ``message``
117
+ is an unhashable dict."""
118
+ a = WaitResult(dict(OTP_MESSAGE))
119
+ b = WaitResult(dict(OTP_MESSAGE))
120
+
121
+ assert a == a
122
+ assert a != b # same field values, but distinct instances
123
+ assert hash(a) == hash(a)
124
+ assert hash(a) != hash(b) or a is b # identity-based hash, no collision assumed
125
+ assert {a, b} == {a, b} # hashable: usable in a set/dict key
126
+
127
+
128
+ def test_wait_for_otp_default_since_is_omitted(monkeypatch):
129
+ """since=None default must OMIT the `since` param so the server uses the
130
+ request start time, not match a stale message already in a reused inbox.
131
+ Revert-proof: restoring since=0 as the default fails this test."""
132
+ client, transport = make_client([(200, {}, {"data": OTP_MESSAGE})])
133
+ _monkeypatch_transport(monkeypatch, client, transport)
134
+
135
+ client.wait_for_otp("inbox_abc", timeout=60)
136
+
137
+ _, url = transport.calls[0]
138
+ query = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)
139
+ assert "since" not in query
140
+
141
+
142
+ def test_wait_for_link_default_since_is_omitted(monkeypatch):
143
+ client, transport = make_client([(200, {}, {"data": LINK_MESSAGE})])
144
+ _monkeypatch_transport(monkeypatch, client, transport)
145
+
146
+ client.wait_for_link("inbox_abc", timeout=60)
147
+
148
+ _, url = transport.calls[0]
149
+ query = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)
150
+ assert "since" not in query
151
+
152
+
153
+ def test_wait_default_since_is_omitted(monkeypatch):
154
+ client, transport = make_client([(200, {}, {"data": OTP_MESSAGE})])
155
+ _monkeypatch_transport(monkeypatch, client, transport)
156
+
157
+ client.wait("inbox_abc", timeout=60)
158
+
159
+ _, url = transport.calls[0]
160
+ query = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)
161
+ assert "since" not in query
162
+
163
+
164
+ def test_wait_for_otp_explicit_since_zero_still_sent(monkeypatch):
165
+ """Explicit since=0 ("include messages already in the inbox") must still
166
+ be forwarded as a literal 0 on the wire."""
167
+ client, transport = make_client([(200, {}, {"data": OTP_MESSAGE})])
168
+ _monkeypatch_transport(monkeypatch, client, transport)
169
+
170
+ client.wait_for_otp("inbox_abc", timeout=60, since=0)
171
+
172
+ _, url = transport.calls[0]
173
+ query = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)
174
+ assert query["since"] == ["0"]
175
+
176
+
112
177
  def test_wait_keeps_polling_through_204s_then_returns(monkeypatch):
113
178
  responses = [
114
179
  (204, {}, None),
@@ -203,8 +268,6 @@ HOSTILE_IDS = [
203
268
  ("in?x=1", "in%3Fx%3D1"),
204
269
  ("in#frag", "in%23frag"),
205
270
  ("../../admin", "..%2F..%2Fadmin"),
206
- ("..", "%2E%2E"),
207
- (".", "%2E"),
208
271
  ("a/b", "a%2Fb"),
209
272
  ("sp ce&%", "sp%20ce%26%25"),
210
273
  ]
@@ -437,18 +500,88 @@ def test_error_types_are_mailsocket_subclasses():
437
500
  assert issubclass(WaitTimeout, MailsocketError)
438
501
 
439
502
 
503
+ # -- F2: _seg rejects "" / "." / ".." before any request (parity with TS) -----
504
+
505
+
506
+ def test_seg_rejects_empty_and_dot_segments():
507
+ for raw in ("", ".", ".."):
508
+ with pytest.raises(MailsocketError) as exc_info:
509
+ mailsocket.client._seg(raw)
510
+ assert exc_info.value.code == "invalid_id"
511
+
512
+
513
+ INVALID_IDS = ["", ".", ".."]
514
+
515
+
516
+ @pytest.mark.parametrize("raw", INVALID_IDS)
517
+ def test_invalid_id_rejected_before_any_request_hits_real_server(raw):
518
+ """Nothing reaches the network: a real local http.server never sees a request."""
519
+ import http.server
520
+ import threading
521
+
522
+ seen = []
523
+
524
+ class Handler(http.server.BaseHTTPRequestHandler):
525
+ def _record(self):
526
+ seen.append(self.path)
527
+ self.send_response(200)
528
+ self.send_header("Content-Type", "application/json")
529
+ self.end_headers()
530
+ self.wfile.write(json.dumps({"data": {"id": "x"}}).encode())
531
+
532
+ def do_GET(self):
533
+ self._record()
534
+
535
+ def do_DELETE(self):
536
+ self._record()
537
+
538
+ def do_POST(self):
539
+ self._record()
540
+
541
+ def log_message(self, format, *args):
542
+ pass # keep test output clean
543
+
544
+ server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
545
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
546
+ thread.start()
547
+ try:
548
+ base_url = f"http://127.0.0.1:{server.server_port}/api/v1"
549
+ client = Client("ms_live_test1234567890", base_url=base_url)
550
+
551
+ calls = [
552
+ lambda: client.get_inbox(raw),
553
+ lambda: client.delete_inbox(raw),
554
+ lambda: client.list_messages(raw),
555
+ lambda: client.get_latest(raw),
556
+ lambda: client.get_message(raw),
557
+ lambda: client.wait_for_otp(raw, timeout=5),
558
+ lambda: client.wait_for_link(raw, timeout=5),
559
+ lambda: client.wait(raw, timeout=5),
560
+ ]
561
+ for call in calls:
562
+ with pytest.raises(MailsocketError) as exc_info:
563
+ call()
564
+ assert exc_info.value.code == "invalid_id"
565
+ assert not isinstance(exc_info.value, WaitTimeout)
566
+
567
+ assert seen == [] # nothing ever reached the network
568
+ finally:
569
+ server.shutdown()
570
+ thread.join(timeout=5)
571
+
572
+
440
573
  def test_version_exported():
441
574
  assert hasattr(mailsocket, "__version__")
442
575
 
443
576
 
444
577
  def test_metadata_consistency_across_files():
445
- """0.1.2 must match everywhere: pyproject, __version__, user agent."""
578
+ """Version must match everywhere: pyproject, __version__, user agent."""
446
579
  import re
447
580
  import tomllib
448
581
  from pathlib import Path
449
582
 
450
583
  root = Path(__file__).resolve().parents[1]
451
- expected = "0.1.2"
584
+ expected = "0.2.0"
452
585
 
453
586
  pyproject = tomllib.loads((root / "pyproject.toml").read_text())
454
587
  assert pyproject["project"]["version"] == expected
File without changes
File without changes