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.
- {mailsocket-0.1.2 → mailsocket-0.2.0}/PKG-INFO +28 -9
- {mailsocket-0.1.2 → mailsocket-0.2.0}/README.md +26 -8
- {mailsocket-0.1.2 → mailsocket-0.2.0}/mailsocket/__init__.py +1 -1
- {mailsocket-0.1.2 → mailsocket-0.2.0}/mailsocket/client.py +57 -19
- mailsocket-0.2.0/mailsocket/py.typed +0 -0
- {mailsocket-0.1.2 → mailsocket-0.2.0}/mailsocket.egg-info/PKG-INFO +28 -9
- {mailsocket-0.1.2 → mailsocket-0.2.0}/mailsocket.egg-info/SOURCES.txt +1 -0
- {mailsocket-0.1.2 → mailsocket-0.2.0}/pyproject.toml +5 -1
- {mailsocket-0.1.2 → mailsocket-0.2.0}/tests/test_client.py +137 -4
- {mailsocket-0.1.2 → mailsocket-0.2.0}/LICENSE +0 -0
- {mailsocket-0.1.2 → mailsocket-0.2.0}/mailsocket/errors.py +0 -0
- {mailsocket-0.1.2 → mailsocket-0.2.0}/mailsocket.egg-info/dependency_links.txt +0 -0
- {mailsocket-0.1.2 → mailsocket-0.2.0}/mailsocket.egg-info/requires.txt +0 -0
- {mailsocket-0.1.2 → mailsocket-0.2.0}/mailsocket.egg-info/top_level.txt +0 -0
- {mailsocket-0.1.2 → mailsocket-0.2.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mailsocket
|
|
3
|
-
Version: 0.
|
|
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=
|
|
76
|
-
result = client.wait_for_link("inbox_abc123", timeout=60, since=
|
|
77
|
-
result = client.wait("inbox_abc123", timeout=60, min_confidence=0.0, since=
|
|
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=
|
|
87
|
-
| `wait_for_link(inbox_id, ...)` | `timeout=60`, `since=
|
|
88
|
-
| `wait(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=
|
|
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
|
|
120
|
-
scripted responder,
|
|
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=
|
|
50
|
-
result = client.wait_for_link("inbox_abc123", timeout=60, since=
|
|
51
|
-
result = client.wait("inbox_abc123", timeout=60, min_confidence=0.0, since=
|
|
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=
|
|
61
|
-
| `wait_for_link(inbox_id, ...)` | `timeout=60`, `since=
|
|
62
|
-
| `wait(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=
|
|
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
|
|
94
|
-
scripted responder,
|
|
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.
|
|
@@ -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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
if
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
self
|
|
83
|
-
self
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
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.
|
|
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=
|
|
76
|
-
result = client.wait_for_link("inbox_abc123", timeout=60, since=
|
|
77
|
-
result = client.wait("inbox_abc123", timeout=60, min_confidence=0.0, since=
|
|
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=
|
|
87
|
-
| `wait_for_link(inbox_id, ...)` | `timeout=60`, `since=
|
|
88
|
-
| `wait(inbox_id, ...)` | `timeout=60`, `min_confidence=0.0`, `since=
|
|
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
|
|
120
|
-
scripted responder,
|
|
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,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mailsocket"
|
|
7
|
-
version = "0.
|
|
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
|
-
"""
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|