oneshot-python 0.15.0__tar.gz → 0.18.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.
Files changed (21) hide show
  1. {oneshot_python-0.15.0 → oneshot_python-0.18.0}/PKG-INFO +2 -2
  2. {oneshot_python-0.15.0 → oneshot_python-0.18.0}/README.md +29 -1
  3. {oneshot_python-0.15.0 → oneshot_python-0.18.0}/oneshot/__init__.py +8 -0
  4. {oneshot_python-0.15.0 → oneshot_python-0.18.0}/oneshot/_types.py +52 -1
  5. {oneshot_python-0.15.0 → oneshot_python-0.18.0}/oneshot/client.py +105 -7
  6. {oneshot_python-0.15.0 → oneshot_python-0.18.0}/oneshot/x402.py +46 -0
  7. {oneshot_python-0.15.0 → oneshot_python-0.18.0}/pyproject.toml +1 -1
  8. oneshot_python-0.18.0/tests/test_domains.py +208 -0
  9. {oneshot_python-0.15.0 → oneshot_python-0.18.0}/tests/test_email_payload.py +12 -0
  10. {oneshot_python-0.15.0 → oneshot_python-0.18.0}/tests/test_tag_receipt_value.py +3 -1
  11. {oneshot_python-0.15.0 → oneshot_python-0.18.0}/.gitignore +0 -0
  12. {oneshot_python-0.15.0 → oneshot_python-0.18.0}/oneshot/_errors.py +0 -0
  13. {oneshot_python-0.15.0 → oneshot_python-0.18.0}/tests/__init__.py +0 -0
  14. {oneshot_python-0.15.0 → oneshot_python-0.18.0}/tests/test_balance.py +0 -0
  15. {oneshot_python-0.15.0 → oneshot_python-0.18.0}/tests/test_compute.py +0 -0
  16. {oneshot_python-0.15.0 → oneshot_python-0.18.0}/tests/test_emergency_error.py +0 -0
  17. {oneshot_python-0.15.0 → oneshot_python-0.18.0}/tests/test_max_cost_header.py +0 -0
  18. {oneshot_python-0.15.0 → oneshot_python-0.18.0}/tests/test_phones_pending.py +0 -0
  19. {oneshot_python-0.15.0 → oneshot_python-0.18.0}/tests/test_request_id.py +0 -0
  20. {oneshot_python-0.15.0 → oneshot_python-0.18.0}/tests/test_x402.py +0 -0
  21. {oneshot_python-0.15.0 → oneshot_python-0.18.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: oneshot-python
3
- Version: 0.15.0
3
+ Version: 0.18.0
4
4
  Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -60,6 +60,12 @@ Paid endpoints use the [x402 protocol](https://x402.org):
60
60
 
61
61
  Your private key never leaves your machine. All signing happens locally via `eth-account`.
62
62
 
63
+ ## Read Authentication
64
+
65
+ Read endpoints (inbox, SMS inbox, notifications, balance, browser profiles) return private, per-agent data. Because a wallet address is public, the client proves you actually control it: on every free/read call it signs a short-lived **EIP-712 read proof** (`sign_read_proof`) and sends it as the `x-agent-proof` header. The API verifies the signature locally and rejects any request whose proof doesn't match the `X-Agent-ID` wallet.
66
+
67
+ This is automatic — no extra code beyond `private_key`. **Requires `oneshot-python >= 0.17.0`.** Older versions keep working until the API enables enforcement, after which they must send a valid `x-agent-proof`.
68
+
63
69
  ## Configuration
64
70
 
65
71
  ```python
@@ -87,6 +93,28 @@ The SDK operates on **Base Mainnet** with real USDC. Fund your wallet before mak
87
93
  - `call_free_post(endpoint, payload=None)` / `acall_free_post(...)` — POST
88
94
  - `call_free_patch(endpoint, payload=None)` / `acall_free_patch(...)` — PATCH
89
95
 
96
+ ### Sending domains — reputation + rotation
97
+
98
+ Free, and scoped to the caller's own domains.
99
+
100
+ ```python
101
+ for d in client.list_domains()["domains"]:
102
+ print(d["domain"], d["warmup_state"], d["warmup_score"], d["warmup_score_updated_at"])
103
+
104
+ client.pause_domain("example.com") # out of rotation, still owned
105
+ client.resume_domain("example.com") # back into rotation
106
+ ```
107
+
108
+ - `list_domains()` / `alist_domains()` — pool + reputation for every domain the agent owns
109
+ - `pause_domain(domain)` / `apause_domain(domain)`
110
+ - `resume_domain(domain)` / `aresume_domain(domain)`
111
+
112
+ `warmup_state` (reputation: `warming` | `warmed` | `degraded`) is orthogonal to `pool_status`
113
+ (rotation: `active` | `paused` | `removed`) — a domain can be paused *and* warming. Two traps when
114
+ reading `warmup_score`: it is `None` for a domain that was never enrolled in warmup, which is not
115
+ the same as a score of `0` (check `warmup_started_at` to tell them apart), and it is refreshed by a
116
+ poll-only reconciler, so it is only as current as `warmup_score_updated_at`.
117
+
90
118
  ### Compute — autonomous goal orchestration
91
119
 
92
120
  Launch a compute goal and let the orchestrator plan, execute, and iterate. Paid via x402 (same flow as other tools).
@@ -127,7 +155,7 @@ Every method has an `a*` async mirror (`acompute`, `aget_compute_goal`, …).
127
155
  ## Links
128
156
 
129
157
  - [Documentation](https://docs.oneshotagent.com/sdk/installation#install-via-pip-python)
130
- - [LangChain integration](https://pypi.org/project/langchain-oneshot/) — 31 tools as LangChain BaseTool
158
+ - [LangChain integration](https://pypi.org/project/langchain-oneshot/) — 34 tools as LangChain BaseTool
131
159
  - [GAME plugin](https://pypi.org/project/game-plugin-oneshot/) — Virtuals Protocol integration
132
160
  - [TypeScript SDK](https://www.npmjs.com/package/@oneshot-agent/sdk)
133
161
  - [MCP Server](https://www.npmjs.com/package/@oneshot-agent/mcp-server)
@@ -20,6 +20,10 @@ from oneshot._types import (
20
20
  ComputeSchedule,
21
21
  ComputeTask,
22
22
  ComputeTaskResponseResult,
23
+ DomainAddressEntry,
24
+ DomainPoolEntry,
25
+ DomainPoolListResult,
26
+ DomainPoolStatusResult,
23
27
  )
24
28
  from oneshot.client import OneShotClient
25
29
  from oneshot.x402 import sign_payment_authorization
@@ -44,4 +48,8 @@ __all__ = [
44
48
  "ComputePauseResult",
45
49
  "ComputeResumeResult",
46
50
  "ComputeFundResult",
51
+ "DomainAddressEntry",
52
+ "DomainPoolEntry",
53
+ "DomainPoolListResult",
54
+ "DomainPoolStatusResult",
47
55
  ]
@@ -1,4 +1,4 @@
1
- """Type hints for the compute orchestration API.
1
+ """Type hints for the compute orchestration and email-domain APIs.
2
2
 
3
3
  These TypedDicts mirror libs/agent-sdk/src/types.ts field-for-field. They are
4
4
  optional at runtime — `OneShotClient` continues to return raw dicts — but
@@ -164,6 +164,53 @@ class ComputeFundResult(TypedDict, total=False):
164
164
  remaining: str
165
165
 
166
166
 
167
+ class DomainAddressEntry(TypedDict, total=False):
168
+ """A mailbox already provisioned on a domain (`addresses[]` in the pool)."""
169
+
170
+ address: str
171
+ status: str # active | provisioning | failed
172
+ warmup_state: str # warming | warmed | degraded — per address
173
+
174
+
175
+ class DomainPoolEntry(TypedDict, total=False):
176
+ """One sending domain. Mirrors `DomainPoolEntry` in TS.
177
+
178
+ `pool_status` is rotation eligibility; `warmup_state` is reputation health.
179
+ They are orthogonal — a domain can be `paused` AND still `warming`.
180
+ """
181
+
182
+ domain: str
183
+ default_from: str
184
+ addresses: list[DomainAddressEntry]
185
+ pool_status: str # active | paused | removed
186
+ warmup_state: str # warming | warmed | degraded
187
+ pause_reason: Optional[str] # warming | low_reputation | manual | None
188
+ provisioning_status: str
189
+ warmup_score: Optional[int]
190
+ warmup_score_updated_at: Optional[str]
191
+ warmup_started_at: Optional[str]
192
+ daily_send_limit: int
193
+ daily_sent_count: int
194
+ daily_sent_date: Optional[str]
195
+ last_used_at: Optional[str]
196
+
197
+
198
+ class DomainPoolListResult(TypedDict, total=False):
199
+ """Returned by `list_domains()`."""
200
+
201
+ agent_id: str
202
+ domains: list[DomainPoolEntry]
203
+
204
+
205
+ class DomainPoolStatusResult(TypedDict, total=False):
206
+ """Returned by `pause_domain()` / `resume_domain()`."""
207
+
208
+ domain: str
209
+ pool_status: str # active | paused
210
+ warmup_state: str
211
+ pause_reason: Optional[str]
212
+
213
+
167
214
  __all__ = [
168
215
  "ComputeSchedule",
169
216
  "ComputeGoalResult",
@@ -175,4 +222,8 @@ __all__ = [
175
222
  "ComputePauseResult",
176
223
  "ComputeResumeResult",
177
224
  "ComputeFundResult",
225
+ "DomainAddressEntry",
226
+ "DomainPoolEntry",
227
+ "DomainPoolListResult",
228
+ "DomainPoolStatusResult",
178
229
  ]
@@ -11,6 +11,7 @@ import asyncio
11
11
  import json
12
12
  import time
13
13
  from typing import Any, Optional
14
+ from urllib.parse import quote
14
15
 
15
16
  import httpx
16
17
  from eth_account import Account
@@ -24,11 +25,16 @@ from oneshot._errors import (
24
25
  ToolError,
25
26
  ValidationError,
26
27
  )
28
+ from oneshot._types import (
29
+ DomainPoolListResult,
30
+ DomainPoolStatusResult,
31
+ )
27
32
  from oneshot.x402 import (
28
33
  build_zero_cost_authorization,
29
34
  encode_payment_header,
30
35
  parse_payment_required,
31
36
  sign_payment_authorization,
37
+ sign_read_proof,
32
38
  )
33
39
 
34
40
  # Derived from the installed package metadata so it never drifts from
@@ -38,7 +44,7 @@ try:
38
44
 
39
45
  SDK_VERSION = _pkg_version("oneshot-python")
40
46
  except Exception: # pragma: no cover - editable/source runs without dist metadata
41
- SDK_VERSION = "0.15.0"
47
+ SDK_VERSION = "0.18.0"
42
48
 
43
49
  # ---------------------------------------------------------------------------
44
50
  # Environment configuration
@@ -62,6 +68,7 @@ def _build_email_payload(
62
68
  extra: dict[str, Any],
63
69
  *,
64
70
  reply_to_email_id: Optional[str] = None,
71
+ mailbox_mode: Optional[str] = None,
65
72
  ) -> dict[str, Any]:
66
73
  """Build the /v1/tools/email/send body in the API's field convention.
67
74
 
@@ -93,6 +100,8 @@ def _build_email_payload(
93
100
  payload["reply_to_email_id"] = reply_to_email_id
94
101
  if from_name:
95
102
  payload["from_name"] = from_name
103
+ if mailbox_mode:
104
+ payload["mailbox_mode"] = mailbox_mode
96
105
  return payload
97
106
 
98
107
 
@@ -138,6 +147,20 @@ class OneShotClient:
138
147
  headers["X-Max-Cost-USDC"] = str(max_cost)
139
148
  return headers
140
149
 
150
+ def _read_headers(self) -> dict[str, str]:
151
+ """Auth headers plus a signed EIP-712 read proof (x-agent-proof) for the
152
+ free READ routes (inbox, sms inbox, notifications, balance, browser
153
+ profiles). These identify the caller by wallet, and wallet addresses are
154
+ public, so without a proof anyone could read another agent's data by
155
+ supplying its address. Signing failure falls back to plain headers (the
156
+ server runs log-only until read-proof enforcement is enabled)."""
157
+ headers = self._headers()
158
+ try:
159
+ headers["x-agent-proof"] = sign_read_proof(self._private_key, self.address)
160
+ except Exception as e: # noqa: BLE001
161
+ self._log(f"Failed to sign read proof (continuing without): {e}")
162
+ return headers
163
+
141
164
  def _log(self, msg: str) -> None:
142
165
  if self.debug:
143
166
  print(f"[OneShot] {msg}")
@@ -362,7 +385,7 @@ class OneShotClient:
362
385
  """GET a free endpoint (async)."""
363
386
  url = f"{self.base_url}{endpoint}"
364
387
  async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
365
- resp = await client.get(url, headers=self._headers(), params=params)
388
+ resp = await client.get(url, headers=self._read_headers(), params=params)
366
389
  if not resp.is_success:
367
390
  raise ToolError(f"GET {endpoint} failed", resp.status_code, resp.text)
368
391
  return resp.json()
@@ -385,7 +408,7 @@ class OneShotClient:
385
408
  """POST to a free endpoint (async)."""
386
409
  url = f"{self.base_url}{endpoint}"
387
410
  async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
388
- resp = await client.post(url, headers=self._headers(), json=payload or {})
411
+ resp = await client.post(url, headers=self._read_headers(), json=payload or {})
389
412
  if not resp.is_success:
390
413
  raise ToolError(f"POST {endpoint} failed", resp.status_code, resp.text)
391
414
  return resp.json()
@@ -408,7 +431,7 @@ class OneShotClient:
408
431
  """PATCH a free endpoint (async)."""
409
432
  url = f"{self.base_url}{endpoint}"
410
433
  async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
411
- resp = await client.patch(url, headers=self._headers(), json=payload or {})
434
+ resp = await client.patch(url, headers=self._read_headers(), json=payload or {})
412
435
  if not resp.is_success:
413
436
  raise ToolError(f"PATCH {endpoint} failed", resp.status_code, resp.text)
414
437
  # PATCH may return empty body (204)
@@ -432,7 +455,7 @@ class OneShotClient:
432
455
  """DELETE a free endpoint (async)."""
433
456
  url = f"{self.base_url}{endpoint}"
434
457
  async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
435
- resp = await client.delete(url, headers=self._headers())
458
+ resp = await client.delete(url, headers=self._read_headers())
436
459
  if not resp.is_success:
437
460
  raise ToolError(f"DELETE {endpoint} failed", resp.status_code, resp.text)
438
461
  if resp.status_code == 204 or not resp.text:
@@ -760,6 +783,7 @@ class OneShotClient:
760
783
  from_mailbox: Optional[str] = None,
761
784
  from_name: Optional[str] = None,
762
785
  reply_to_email_id: Optional[str] = None,
786
+ mailbox_mode: Optional[str] = None,
763
787
  **kwargs: Any,
764
788
  ) -> Any:
765
789
  """Send an email. Blocking.
@@ -767,10 +791,21 @@ class OneShotClient:
767
791
  Set ``reply_to_email_id`` (an inbound email id from ``inbox()``) to
768
792
  reply within a thread — ``to``/``subject`` are then derived from the
769
793
  inbound message unless given.
794
+
795
+ The first send from any ``from_mailbox`` on an owned domain — including
796
+ the default ``agent@`` — provisions a mailbox per address and adds a
797
+ one-time ``mailbox_provisioning_fee`` to the quote; it's free only once
798
+ that address is already provisioned (``active``).
799
+
800
+ ``mailbox_mode`` ('relay' | 'mailbox', default 'relay') selects how a
801
+ domain sends: 'relay' is header-only send (no per-address mailbox, no
802
+ mailbox fee); 'mailbox' provisions a real dedicated mailbox per address
803
+ (better deliverability + per-address warmup). Only applies when a domain
804
+ is first set up — use it on a new ``from_domain``.
770
805
  """
771
806
  return self.call_tool(
772
807
  "/v1/tools/email/send",
773
- _build_email_payload(to, subject, body, from_domain, from_mailbox, from_name, kwargs, reply_to_email_id=reply_to_email_id),
808
+ _build_email_payload(to, subject, body, from_domain, from_mailbox, from_name, kwargs, reply_to_email_id=reply_to_email_id, mailbox_mode=mailbox_mode),
774
809
  )
775
810
 
776
811
  async def aemail(
@@ -783,6 +818,7 @@ class OneShotClient:
783
818
  from_mailbox: Optional[str] = None,
784
819
  from_name: Optional[str] = None,
785
820
  reply_to_email_id: Optional[str] = None,
821
+ mailbox_mode: Optional[str] = None,
786
822
  **kwargs: Any,
787
823
  ) -> Any:
788
824
  """Send an email. Async.
@@ -790,10 +826,13 @@ class OneShotClient:
790
826
  Set ``reply_to_email_id`` (an inbound email id from ``ainbox()``) to
791
827
  reply within a thread — ``to``/``subject`` are then derived from the
792
828
  inbound message unless given.
829
+
830
+ ``mailbox_mode`` ('relay' | 'mailbox', default 'relay') selects how a
831
+ domain sends — see ``email()`` for details.
793
832
  """
794
833
  return await self.acall_tool(
795
834
  "/v1/tools/email/send",
796
- _build_email_payload(to, subject, body, from_domain, from_mailbox, from_name, kwargs, reply_to_email_id=reply_to_email_id),
835
+ _build_email_payload(to, subject, body, from_domain, from_mailbox, from_name, kwargs, reply_to_email_id=reply_to_email_id, mailbox_mode=mailbox_mode),
797
836
  )
798
837
 
799
838
  def voice(self, objective: str, target_number: str, **kwargs: Any) -> Any:
@@ -958,6 +997,65 @@ class OneShotClient:
958
997
  """Get unified balance (on-chain USDC + credits). Async."""
959
998
  return await self.acall_free_get("/v1/tools/balance")
960
999
 
1000
+ # ------------------------------------------------------------------
1001
+ # Email domains — rotation pool + sender reputation
1002
+ # ------------------------------------------------------------------
1003
+ # All three endpoints are free. `pause`/`resume` sit behind the API's
1004
+ # paidEndpointLimiter, but that is a rate limiter, not a price — no x402
1005
+ # quote is issued, so they go through `call_free_post`, not `call_tool`.
1006
+
1007
+ def list_domains(self) -> DomainPoolListResult:
1008
+ """List the caller's sending domains with reputation + rotation state. Blocking.
1009
+
1010
+ Each entry carries `pool_status` (rotation eligibility: active | paused |
1011
+ removed), `warmup_state` (reputation health: warming | warmed | degraded),
1012
+ `pause_reason`, `warmup_score`, daily send counters, and `addresses[]` —
1013
+ the mailboxes already provisioned on the domain, each with its own
1014
+ `warmup_state`.
1015
+
1016
+ Two things to know before trusting `warmup_score`:
1017
+
1018
+ - It is ``None`` when the domain was never enrolled in warmup. That is not
1019
+ the same as "enrolled, no score yet" — check `warmup_started_at` to tell
1020
+ the two apart.
1021
+ - Scores are refreshed by a poll-only reconciler, so the number is only as
1022
+ fresh as `warmup_score_updated_at`. A stale timestamp means a stale score,
1023
+ not a current measurement.
1024
+ """
1025
+ return self.call_free_get("/v1/tools/email/domains")
1026
+
1027
+ async def alist_domains(self) -> DomainPoolListResult:
1028
+ """List the caller's sending domains with reputation + rotation state. Async."""
1029
+ return await self.acall_free_get("/v1/tools/email/domains")
1030
+
1031
+ def pause_domain(self, domain: str) -> DomainPoolStatusResult:
1032
+ """Take a domain out of rotation without releasing it. Blocking."""
1033
+ return asyncio.get_event_loop().run_until_complete(self.apause_domain(domain))
1034
+
1035
+ async def apause_domain(self, domain: str) -> DomainPoolStatusResult:
1036
+ """Take a domain out of rotation without releasing it. Async."""
1037
+ return await self.acall_free_post(self._domain_action_path(domain, "pause"))
1038
+
1039
+ def resume_domain(self, domain: str) -> DomainPoolStatusResult:
1040
+ """Put a paused domain back into rotation. Blocking."""
1041
+ return asyncio.get_event_loop().run_until_complete(self.aresume_domain(domain))
1042
+
1043
+ async def aresume_domain(self, domain: str) -> DomainPoolStatusResult:
1044
+ """Put a paused domain back into rotation. Async.
1045
+
1046
+ Reputation pauses (`pause_reason='low_reputation'`) auto-recover once the
1047
+ domain is warmed again; a manual pause needs this call.
1048
+ """
1049
+ return await self.acall_free_post(self._domain_action_path(domain, "resume"))
1050
+
1051
+ @staticmethod
1052
+ def _domain_action_path(domain: str, action: str) -> str:
1053
+ """Build `/v1/tools/email/domains/<domain>/<action>` with the domain
1054
+ percent-encoded (matches the TS SDK's encodeURIComponent)."""
1055
+ if not domain:
1056
+ raise ValidationError("domain is required", "domain")
1057
+ return f"/v1/tools/email/domains/{quote(domain, safe='')}/{action}"
1058
+
961
1059
  # ------------------------------------------------------------------
962
1060
  # Receipts — value tagging (RoCS)
963
1061
  # ------------------------------------------------------------------
@@ -249,3 +249,49 @@ def _parse_usdc_amount(amount: str) -> int:
249
249
  # Pad or truncate fractional part to 6 digits
250
250
  frac_str = parts[1][:6].ljust(6, "0")
251
251
  return whole * 1_000_000 + int(frac_str)
252
+
253
+
254
+ def sign_read_proof(private_key: str, address: str, scope: str = "read") -> str:
255
+ """Build a signed EIP-712 agent read proof — the `x-agent-proof` header value.
256
+
257
+ Mirrors the TS SDK's signedReadHeaders() and the server's AgentReadAuth
258
+ verification (api-service middleware/agent-proof-auth.ts). The agent READ
259
+ routes (inbox, sms inbox, notifications, balance, browser profiles) identify
260
+ the caller by wallet; without a proof anyone could read another agent's data
261
+ by supplying its public address. This binds the request to the wallet the SDK
262
+ controls. A fresh nonce per call bounds replay.
263
+ """
264
+ domain_data = {"name": "OneShot Agent Auth", "version": "1"}
265
+ message_types = {
266
+ "AgentReadAuth": [
267
+ {"name": "agent", "type": "address"},
268
+ {"name": "scope", "type": "string"},
269
+ {"name": "issuedAt", "type": "uint256"},
270
+ {"name": "nonce", "type": "bytes32"},
271
+ ],
272
+ }
273
+ issued_at = int(time.time())
274
+ nonce_bytes = os.urandom(32)
275
+ message_data = {
276
+ "agent": address,
277
+ "scope": scope,
278
+ "issuedAt": issued_at,
279
+ "nonce": nonce_bytes,
280
+ }
281
+ signable = encode_typed_data(
282
+ domain_data=domain_data,
283
+ message_types=message_types,
284
+ message_data=message_data,
285
+ )
286
+ signed = Account.sign_message(signable, private_key=private_key)
287
+ signature = "0x" + (
288
+ signed.signature.hex() if isinstance(signed.signature, bytes) else format(signed.signature, "x")
289
+ )
290
+ proof = {
291
+ "agent": address,
292
+ "scope": scope,
293
+ "issuedAt": issued_at,
294
+ "nonce": "0x" + nonce_bytes.hex(),
295
+ "signature": signature,
296
+ }
297
+ return base64.b64encode(json.dumps(proof).encode("utf-8")).decode("ascii")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "oneshot-python"
3
- version = "0.15.0"
3
+ version = "0.18.0"
4
4
  description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling"
5
5
  readme = {text = "Core Python SDK for the OneShot API", content-type = "text/plain"}
6
6
  license = "MIT"
@@ -0,0 +1,208 @@
1
+ """Tests for the email-domain methods on OneShotClient.
2
+
3
+ Covers list_domains / pause_domain / resume_domain (sync + async): the exact
4
+ endpoints hit, percent-encoding of the domain path segment, and the empty-domain
5
+ guard. All three endpoints are free, so they must route through the
6
+ `call_free_get` / `call_free_post` helpers — never `call_tool` (which would open
7
+ an x402 quote-and-pay flow for a read).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from unittest.mock import AsyncMock, patch
13
+
14
+ import pytest
15
+ from eth_account import Account
16
+
17
+ from oneshot._errors import ValidationError
18
+ from oneshot.client import OneShotClient
19
+
20
+ # Deterministic test key (never use with real funds)
21
+ TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
22
+ TEST_ADDRESS = Account.from_key(TEST_PRIVATE_KEY).address
23
+
24
+ MOCK_DOMAINS_RESPONSE = {
25
+ "agent_id": "39c6fd04-acfc-4be4-bb35-0e8f6523d8e7",
26
+ "domains": [
27
+ {
28
+ "domain": "warmed-example.com",
29
+ "default_from": "agent@warmed-example.com",
30
+ "addresses": [
31
+ {
32
+ "address": "agent@warmed-example.com",
33
+ "status": "active",
34
+ "warmup_state": "warmed",
35
+ }
36
+ ],
37
+ "pool_status": "active",
38
+ "warmup_state": "warmed",
39
+ "pause_reason": None,
40
+ "provisioning_status": "active",
41
+ "warmup_score": 98,
42
+ "warmup_score_updated_at": "2026-08-13T18:00:05.497Z",
43
+ "warmup_started_at": "2026-06-25T14:07:43.230Z",
44
+ "daily_send_limit": 100,
45
+ "daily_sent_count": 5,
46
+ "daily_sent_date": "2026-08-13",
47
+ "last_used_at": "2026-08-13T17:12:00.000Z",
48
+ },
49
+ {
50
+ # Never enrolled in warmup: score is None, not 0. Callers must not
51
+ # read this as "reputation zero".
52
+ "domain": "never-enrolled.com",
53
+ "default_from": "agent@never-enrolled.com",
54
+ "addresses": [],
55
+ "pool_status": "paused",
56
+ "warmup_state": "warming",
57
+ "pause_reason": "warming",
58
+ "provisioning_status": "active",
59
+ "warmup_score": None,
60
+ "warmup_score_updated_at": None,
61
+ "warmup_started_at": None,
62
+ "daily_send_limit": 50,
63
+ "daily_sent_count": 0,
64
+ "daily_sent_date": None,
65
+ "last_used_at": None,
66
+ },
67
+ ],
68
+ }
69
+
70
+ MOCK_STATUS_RESPONSE = {
71
+ "domain": "warmed-example.com",
72
+ "pool_status": "paused",
73
+ "warmup_state": "warmed",
74
+ "pause_reason": "manual",
75
+ }
76
+
77
+
78
+ class TestListDomains:
79
+ """Synchronous list_domains() tests."""
80
+
81
+ def test_calls_correct_endpoint(self) -> None:
82
+ client = OneShotClient(TEST_PRIVATE_KEY)
83
+
84
+ with patch.object(client, "call_free_get", return_value=MOCK_DOMAINS_RESPONSE) as mock:
85
+ result = client.list_domains()
86
+
87
+ mock.assert_called_once_with("/v1/tools/email/domains")
88
+ assert result == MOCK_DOMAINS_RESPONSE
89
+
90
+ def test_surfaces_reputation_fields(self) -> None:
91
+ client = OneShotClient(TEST_PRIVATE_KEY)
92
+
93
+ with patch.object(client, "call_free_get", return_value=MOCK_DOMAINS_RESPONSE):
94
+ domains = client.list_domains()["domains"]
95
+
96
+ warmed, never_enrolled = domains
97
+ assert warmed["warmup_state"] == "warmed"
98
+ assert warmed["warmup_score"] == 98
99
+ assert warmed["warmup_score_updated_at"] == "2026-08-13T18:00:05.497Z"
100
+ assert warmed["addresses"][0]["warmup_state"] == "warmed"
101
+ # Never enrolled: null score, null started_at — distinct from a real 0.
102
+ assert never_enrolled["warmup_score"] is None
103
+ assert never_enrolled["warmup_started_at"] is None
104
+
105
+ def test_propagates_errors(self) -> None:
106
+ client = OneShotClient(TEST_PRIVATE_KEY)
107
+
108
+ with patch.object(client, "call_free_get", side_effect=Exception("Network error")):
109
+ with pytest.raises(Exception, match="Network error"):
110
+ client.list_domains()
111
+
112
+
113
+ class TestAlistDomains:
114
+ """Async alist_domains() tests."""
115
+
116
+ @pytest.mark.asyncio
117
+ async def test_calls_correct_endpoint(self) -> None:
118
+ client = OneShotClient(TEST_PRIVATE_KEY)
119
+
120
+ with patch.object(
121
+ client, "acall_free_get", new_callable=AsyncMock, return_value=MOCK_DOMAINS_RESPONSE
122
+ ) as mock:
123
+ result = await client.alist_domains()
124
+
125
+ mock.assert_called_once_with("/v1/tools/email/domains")
126
+ assert result == MOCK_DOMAINS_RESPONSE
127
+
128
+ @pytest.mark.asyncio
129
+ async def test_propagates_errors(self) -> None:
130
+ client = OneShotClient(TEST_PRIVATE_KEY)
131
+
132
+ with patch.object(
133
+ client, "acall_free_get", new_callable=AsyncMock, side_effect=Exception("Timeout")
134
+ ):
135
+ with pytest.raises(Exception, match="Timeout"):
136
+ await client.alist_domains()
137
+
138
+
139
+ class TestPauseResumeDomain:
140
+ """pause_domain() / resume_domain(), sync + async."""
141
+
142
+ @pytest.mark.asyncio
143
+ async def test_pause_calls_correct_endpoint(self) -> None:
144
+ client = OneShotClient(TEST_PRIVATE_KEY)
145
+
146
+ with patch.object(
147
+ client, "acall_free_post", new_callable=AsyncMock, return_value=MOCK_STATUS_RESPONSE
148
+ ) as mock:
149
+ result = await client.apause_domain("warmed-example.com")
150
+
151
+ mock.assert_called_once_with("/v1/tools/email/domains/warmed-example.com/pause")
152
+ assert result == MOCK_STATUS_RESPONSE
153
+
154
+ @pytest.mark.asyncio
155
+ async def test_resume_calls_correct_endpoint(self) -> None:
156
+ client = OneShotClient(TEST_PRIVATE_KEY)
157
+
158
+ with patch.object(
159
+ client, "acall_free_post", new_callable=AsyncMock, return_value=MOCK_STATUS_RESPONSE
160
+ ) as mock:
161
+ await client.aresume_domain("warmed-example.com")
162
+
163
+ mock.assert_called_once_with("/v1/tools/email/domains/warmed-example.com/resume")
164
+
165
+ @pytest.mark.asyncio
166
+ async def test_encodes_the_domain_segment(self) -> None:
167
+ """A domain with a slash must not escape into a different route."""
168
+ client = OneShotClient(TEST_PRIVATE_KEY)
169
+
170
+ with patch.object(
171
+ client, "acall_free_post", new_callable=AsyncMock, return_value=MOCK_STATUS_RESPONSE
172
+ ) as mock:
173
+ await client.apause_domain("evil.com/../../admin")
174
+
175
+ mock.assert_called_once_with(
176
+ "/v1/tools/email/domains/evil.com%2F..%2F..%2Fadmin/pause"
177
+ )
178
+
179
+ @pytest.mark.asyncio
180
+ async def test_rejects_empty_domain(self) -> None:
181
+ client = OneShotClient(TEST_PRIVATE_KEY)
182
+
183
+ with pytest.raises(ValidationError):
184
+ await client.apause_domain("")
185
+ with pytest.raises(ValidationError):
186
+ await client.aresume_domain("")
187
+
188
+ def test_sync_pause_delegates_to_async(self) -> None:
189
+ client = OneShotClient(TEST_PRIVATE_KEY)
190
+
191
+ with patch.object(
192
+ client, "apause_domain", new_callable=AsyncMock, return_value=MOCK_STATUS_RESPONSE
193
+ ) as mock:
194
+ result = client.pause_domain("warmed-example.com")
195
+
196
+ mock.assert_called_once_with("warmed-example.com")
197
+ assert result == MOCK_STATUS_RESPONSE
198
+
199
+ def test_sync_resume_delegates_to_async(self) -> None:
200
+ client = OneShotClient(TEST_PRIVATE_KEY)
201
+
202
+ with patch.object(
203
+ client, "aresume_domain", new_callable=AsyncMock, return_value=MOCK_STATUS_RESPONSE
204
+ ) as mock:
205
+ result = client.resume_domain("warmed-example.com")
206
+
207
+ mock.assert_called_once_with("warmed-example.com")
208
+ assert result == MOCK_STATUS_RESPONSE
@@ -46,6 +46,18 @@ def test_payload_passes_extra_kwargs():
46
46
  assert p["wait"] is True
47
47
 
48
48
 
49
+ def test_payload_forwards_mailbox_mode():
50
+ # mailbox_mode is an explicit keyword-only param, forwarded into the send body so
51
+ # the API's per-domain provider override + fee logic sees it.
52
+ p = _build_email_payload("r@x.com", "S", "B", "acme.com", None, None, {}, mailbox_mode="mailbox")
53
+ assert p["mailbox_mode"] == "mailbox"
54
+
55
+
56
+ def test_payload_omits_mailbox_mode_when_absent():
57
+ p = _build_email_payload("r@x.com", "S", "B", "acme.com", None, None, {})
58
+ assert "mailbox_mode" not in p
59
+
60
+
49
61
  # ── reply threading (reply_to_email_id) ───────────────────────────────────
50
62
 
51
63
  def test_reply_payload_omits_to_and_subject_when_derivable():
@@ -105,10 +105,11 @@ def test_sync_wrapper_delegates_to_async(monkeypatch):
105
105
  c = OneShotClient(TEST_PRIVATE_KEY)
106
106
  called: dict = {}
107
107
 
108
- async def fake_async(receipt_id=None, value_tag=None, *, request_id=None):
108
+ async def fake_async(receipt_id=None, value_tag=None, *, request_id=None, goal_id=None):
109
109
  called["receipt_id"] = receipt_id
110
110
  called["value_tag"] = value_tag
111
111
  called["request_id"] = request_id
112
+ called["goal_id"] = goal_id
112
113
  return {"ok": True}
113
114
 
114
115
  c.atag_receipt_value = fake_async # type: ignore[method-assign]
@@ -118,4 +119,5 @@ def test_sync_wrapper_delegates_to_async(monkeypatch):
118
119
  "receipt_id": "rcpt_01HX",
119
120
  "value_tag": {"type": "savings", "amount": 12},
120
121
  "request_id": None,
122
+ "goal_id": None,
121
123
  }
File without changes