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.
- {mailsocket-0.1.0 → mailsocket-0.1.2}/PKG-INFO +17 -5
- {mailsocket-0.1.0 → mailsocket-0.1.2}/README.md +14 -4
- {mailsocket-0.1.0 → mailsocket-0.1.2}/mailsocket/__init__.py +1 -1
- {mailsocket-0.1.0 → mailsocket-0.1.2}/mailsocket/client.py +44 -8
- {mailsocket-0.1.0 → mailsocket-0.1.2}/mailsocket.egg-info/PKG-INFO +17 -5
- {mailsocket-0.1.0 → mailsocket-0.1.2}/pyproject.toml +3 -1
- {mailsocket-0.1.0 → mailsocket-0.1.2}/tests/test_client.py +170 -0
- {mailsocket-0.1.0 → mailsocket-0.1.2}/LICENSE +0 -0
- {mailsocket-0.1.0 → mailsocket-0.1.2}/mailsocket/errors.py +0 -0
- {mailsocket-0.1.0 → mailsocket-0.1.2}/mailsocket.egg-info/SOURCES.txt +0 -0
- {mailsocket-0.1.0 → mailsocket-0.1.2}/mailsocket.egg-info/dependency_links.txt +0 -0
- {mailsocket-0.1.0 → mailsocket-0.1.2}/mailsocket.egg-info/requires.txt +0 -0
- {mailsocket-0.1.0 → mailsocket-0.1.2}/mailsocket.egg-info/top_level.txt +0 -0
- {mailsocket-0.1.0 → mailsocket-0.1.2}/setup.cfg +0 -0
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mailsocket
|
|
3
|
-
Version: 0.1.
|
|
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(
|
|
74
|
-
result = client.wait_for_link(
|
|
75
|
-
result = client.wait(
|
|
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
|
|
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(
|
|
50
|
-
result = client.wait_for_link(
|
|
51
|
-
result = client.wait(
|
|
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
|
|
89
|
+
cd python
|
|
80
90
|
python -m pytest -q
|
|
81
91
|
```
|
|
82
92
|
|
|
@@ -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.
|
|
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.
|
|
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(
|
|
74
|
-
result = client.wait_for_link(
|
|
75
|
-
result = client.wait(
|
|
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
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|