mailsocket 0.1.3__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.3
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).
@@ -123,4 +131,9 @@ invalid id never reaches the network.
123
131
 
124
132
  ## Changelog
125
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.
126
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).
@@ -97,4 +104,9 @@ invalid id never reaches the network.
97
104
 
98
105
  ## Changelog
99
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.
100
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.3"
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
 
@@ -79,14 +80,30 @@ class Page:
79
80
  return f"Page(data={self.data!r}, pagination={self.pagination!r})"
80
81
 
81
82
 
83
+ @dataclass(frozen=True, eq=False)
82
84
  class WaitResult:
83
- """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)
84
100
 
85
- def __init__(self, message: dict):
86
- self.message = message
87
- self.otp = message.get("otp")
88
- self.confidence = message.get("otp_confidence")
89
- 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"))
90
107
 
91
108
  @property
92
109
  def link(self):
@@ -192,9 +209,16 @@ class Client:
192
209
  *,
193
210
  timeout: float = 60.0,
194
211
  min_confidence: float = 0.0,
195
- since=0,
212
+ since=None,
196
213
  ) -> WaitResult:
197
- """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
+ """
198
222
  return self._wait(
199
223
  inbox_id,
200
224
  require="otp",
@@ -203,8 +227,12 @@ class Client:
203
227
  since=since,
204
228
  )
205
229
 
206
- def wait_for_link(self, inbox_id: str, *, timeout: float = 60.0, since=0) -> WaitResult:
207
- """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
+ """
208
236
  return self._wait(
209
237
  inbox_id, require="link", timeout=timeout, min_confidence=None, since=since
210
238
  )
@@ -215,9 +243,13 @@ class Client:
215
243
  *,
216
244
  timeout: float = 60.0,
217
245
  min_confidence: float = 0.0,
218
- since=0,
246
+ since=None,
219
247
  ) -> WaitResult:
220
- """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
+ """
221
253
  return self._wait(
222
254
  inbox_id,
223
255
  require="any",
@@ -288,7 +320,7 @@ class Client:
288
320
  url += "?" + urllib.parse.urlencode(params, doseq=True)
289
321
  headers = {
290
322
  "Accept": "application/json",
291
- "User-Agent": "mailsocket-python/0.1.3",
323
+ "User-Agent": "mailsocket-python/0.2.0",
292
324
  }
293
325
  headers.update(self._extra_headers)
294
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.3
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).
@@ -123,4 +131,9 @@ invalid id never reaches the network.
123
131
 
124
132
  ## Changelog
125
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.
126
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.3"
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),
@@ -510,13 +575,13 @@ def test_version_exported():
510
575
 
511
576
 
512
577
  def test_metadata_consistency_across_files():
513
- """0.1.3 must match everywhere: pyproject, __version__, user agent."""
578
+ """Version must match everywhere: pyproject, __version__, user agent."""
514
579
  import re
515
580
  import tomllib
516
581
  from pathlib import Path
517
582
 
518
583
  root = Path(__file__).resolve().parents[1]
519
- expected = "0.1.3"
584
+ expected = "0.2.0"
520
585
 
521
586
  pyproject = tomllib.loads((root / "pyproject.toml").read_text())
522
587
  assert pyproject["project"]["version"] == expected
File without changes
File without changes