oneshot-python 0.20.2__tar.gz → 0.24.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.
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/.gitignore +1 -0
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/PKG-INFO +2 -2
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/README.md +14 -1
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/oneshot/__init__.py +3 -0
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/oneshot/_errors.py +3 -1
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/oneshot/client.py +162 -20
- oneshot_python-0.24.0/oneshot/physical_mail.py +168 -0
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/pyproject.toml +2 -2
- oneshot_python-0.24.0/tests/test_browser.py +57 -0
- oneshot_python-0.24.0/tests/test_email_payload.py +282 -0
- oneshot_python-0.24.0/tests/test_physical_mail.py +43 -0
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/uv.lock +1 -1
- oneshot_python-0.20.2/tests/test_email_payload.py +0 -138
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/oneshot/_types.py +0 -0
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/oneshot/x402.py +0 -0
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/tests/__init__.py +0 -0
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/tests/test_balance.py +0 -0
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/tests/test_budgets.py +0 -0
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/tests/test_charge_amount.py +0 -0
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/tests/test_compute.py +0 -0
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/tests/test_domains.py +0 -0
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/tests/test_emergency_error.py +0 -0
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/tests/test_max_cost_header.py +0 -0
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/tests/test_payment_rejection.py +0 -0
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/tests/test_phones_pending.py +0 -0
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/tests/test_poll_backoff.py +0 -0
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/tests/test_request_id.py +0 -0
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/tests/test_tag_receipt_value.py +0 -0
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/tests/test_wait_false.py +0 -0
- {oneshot_python-0.20.2 → oneshot_python-0.24.0}/tests/test_x402.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: oneshot-python
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling
|
|
3
|
+
Version: 0.24.0
|
|
4
|
+
Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 35 tools
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: >=3.10
|
|
7
7
|
Requires-Dist: eth-account>=0.13.0
|
|
@@ -192,7 +192,7 @@ Every method has an `a*` async mirror (`acompute`, `aget_compute_goal`, …).
|
|
|
192
192
|
## Links
|
|
193
193
|
|
|
194
194
|
- [Documentation](https://docs.oneshotagent.com/sdk/installation#install-via-pip-python)
|
|
195
|
-
- [LangChain integration](https://pypi.org/project/langchain-oneshot/) —
|
|
195
|
+
- [LangChain integration](https://pypi.org/project/langchain-oneshot/) — 35 tools as LangChain BaseTool
|
|
196
196
|
- [GAME plugin](https://pypi.org/project/game-plugin-oneshot/) — Virtuals Protocol integration
|
|
197
197
|
- [TypeScript SDK](https://www.npmjs.com/package/@oneshot-agent/sdk)
|
|
198
198
|
- [MCP Server](https://www.npmjs.com/package/@oneshot-agent/mcp-server)
|
|
@@ -201,3 +201,16 @@ Every method has an `a*` async mirror (`acompute`, `aget_compute_goal`, …).
|
|
|
201
201
|
## License
|
|
202
202
|
|
|
203
203
|
MIT
|
|
204
|
+
|
|
205
|
+
### Physical mail
|
|
206
|
+
|
|
207
|
+
`client.physical_mail` supports U.S. letters and 4×6 postcards through `upload_artwork`, `validate_address`, `preview`, `get_quote`, `approve`, `send`, `get_order`, `recover`, and `cancel`. Async variants prefix the method with `a`. Review each proof and price, then call `approve(..., approved=True)` explicitly. Persist `idempotency_key` before sending. `recover(key)` retrieves the original order without creating another charge. Postal delivery does not prove readership; paid receipts and fulfillment events remain separate. Lob credentials stay on OneShot's server.
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
### Account login and 2FA (0.24.0+)
|
|
211
|
+
|
|
212
|
+
Use `create_browser_profile(name)`, then `start_browser_profile_setup(profile_id, login_url)`. Poll `get_browser_profile_setup(profile_id)` until `status` is `idle`, and show the private `live_url` to the user. The live browser currently uses the Browser-Use domain. The user enters their credentials and completes 2FA there; after they finish, call `finish_browser_profile_setup(profile_id)` to save the session. Async variants use an `a` prefix.
|
|
213
|
+
|
|
214
|
+
Finish inspects cookie metadata in a fresh browser before navigation, without returning values. Later `browser(profile_id=..., task=...)` calls reuse the saved profile; verify site authentication with a read before relying on it. Setup expires after 15 minutes, with abandoned-session cleanup by the lifecycle sweep; the profile is retained for reconnection. Never log private live URLs or put credentials/codes in task prompts.
|
|
215
|
+
|
|
216
|
+
Alternatively, `create_browser_profile(name, cookies=[...])` or `storage_state={...}` imports cookies and localStorage. The legacy browser `secrets` field is rejected, and `max_steps` must be an integer from 25 through 100. Failed `JobError`s expose `code` and `partial_result`. Browser `cost` is provider cost; `billed_cost` is the reconciled customer charge, or null while pending.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""oneshot-python — Core Python SDK for the OneShot API."""
|
|
2
2
|
|
|
3
|
+
from oneshot.physical_mail import PhysicalMail, PostalAddress, MailQuote, MailOrder
|
|
4
|
+
|
|
3
5
|
from oneshot._errors import (
|
|
4
6
|
BudgetExceededError,
|
|
5
7
|
BudgetSyncError,
|
|
@@ -34,6 +36,7 @@ from oneshot.client import OneShotClient
|
|
|
34
36
|
from oneshot.x402 import sign_payment_authorization
|
|
35
37
|
|
|
36
38
|
__all__ = [
|
|
39
|
+
"PhysicalMail", "PostalAddress", "MailQuote", "MailOrder",
|
|
37
40
|
"OneShotClient",
|
|
38
41
|
"OneShotError",
|
|
39
42
|
"ToolError",
|
|
@@ -44,10 +44,12 @@ class PaymentError(OneShotError):
|
|
|
44
44
|
class JobError(OneShotError):
|
|
45
45
|
"""Async job completed with an error."""
|
|
46
46
|
|
|
47
|
-
def __init__(self, message: str, job_id: str, job_error: str) -> None:
|
|
47
|
+
def __init__(self, message: str, job_id: str, job_error: str, code: str | None = None, partial_result: dict | None = None) -> None:
|
|
48
48
|
super().__init__(message)
|
|
49
49
|
self.job_id = job_id
|
|
50
50
|
self.job_error = job_error
|
|
51
|
+
self.code = code
|
|
52
|
+
self.partial_result = partial_result
|
|
51
53
|
|
|
52
54
|
|
|
53
55
|
class JobTimeoutError(OneShotError):
|
|
@@ -51,7 +51,7 @@ try:
|
|
|
51
51
|
|
|
52
52
|
SDK_VERSION = _pkg_version("oneshot-python")
|
|
53
53
|
except Exception: # pragma: no cover - editable/source runs without dist metadata
|
|
54
|
-
SDK_VERSION = "0.
|
|
54
|
+
SDK_VERSION = "0.24.0"
|
|
55
55
|
|
|
56
56
|
# ---------------------------------------------------------------------------
|
|
57
57
|
# Environment configuration
|
|
@@ -95,13 +95,26 @@ def _build_email_payload(
|
|
|
95
95
|
raise ValidationError("to is required unless reply_to_email_id is set", "to")
|
|
96
96
|
if not subject:
|
|
97
97
|
raise ValidationError("subject is required unless reply_to_email_id is set", "subject")
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
# Rotation mode: when the caller passes neither from_domain nor
|
|
99
|
+
# from_mailbox, omit from_address entirely so the server picks from the
|
|
100
|
+
# agent's domain pool (mirrors the TS SDK's rotation contract). Pinning
|
|
101
|
+
# from_mailbox alone has no domain to attach it to — "oneshotagent.com"
|
|
102
|
+
# is OneShot's own domain and no agent can send from it — so that
|
|
103
|
+
# combination is a client-side error rather than a guaranteed
|
|
104
|
+
# domain_not_owned rejection from the server.
|
|
100
105
|
payload: dict[str, Any] = {
|
|
101
|
-
"from_address": f"{mailbox}@{domain}",
|
|
102
106
|
"body": body,
|
|
103
107
|
**extra,
|
|
104
108
|
}
|
|
109
|
+
if from_domain:
|
|
110
|
+
mailbox = from_mailbox or "agent"
|
|
111
|
+
payload["from_address"] = f"{mailbox}@{from_domain}"
|
|
112
|
+
elif from_mailbox:
|
|
113
|
+
raise ValidationError(
|
|
114
|
+
"from_mailbox was pinned without from_domain. Pass from_domain (a domain you own) as well, "
|
|
115
|
+
"or omit both to rotate across your warmed domains.",
|
|
116
|
+
"from_domain",
|
|
117
|
+
)
|
|
105
118
|
if to is not None:
|
|
106
119
|
payload["to_address"] = to
|
|
107
120
|
if subject is not None:
|
|
@@ -313,6 +326,11 @@ class OneShotClient:
|
|
|
313
326
|
headers["X-Max-Cost-USDC"] = str(max_cost)
|
|
314
327
|
return headers
|
|
315
328
|
|
|
329
|
+
@property
|
|
330
|
+
def physical_mail(self):
|
|
331
|
+
from oneshot.physical_mail import PhysicalMail
|
|
332
|
+
return PhysicalMail(self)
|
|
333
|
+
|
|
316
334
|
def _read_headers(self) -> dict[str, str]:
|
|
317
335
|
"""Auth headers plus a signed EIP-712 read proof (x-agent-proof) for the
|
|
318
336
|
free READ routes (inbox, sms inbox, notifications, balance, browser
|
|
@@ -500,11 +518,17 @@ class OneShotClient:
|
|
|
500
518
|
wait: bool = True,
|
|
501
519
|
wait_for_phones: bool = False,
|
|
502
520
|
phone_timeout_sec: int = 360,
|
|
521
|
+
quote_id: Optional[str] = None,
|
|
503
522
|
) -> Any:
|
|
504
523
|
"""Execute a paid tool call (async). Handles the full x402 flow.
|
|
505
524
|
|
|
506
525
|
With ``wait=False`` a queued job is returned as
|
|
507
526
|
``{"request_id": ..., "status": ...}`` instead of being polled.
|
|
527
|
+
|
|
528
|
+
Pass ``quote_id`` when the caller already obtained a quote for this
|
|
529
|
+
exact request (e.g. ``/email/send`` after ``/email/quote``) — it rides
|
|
530
|
+
on the ``x-quote-id`` header from the very first attempt, since
|
|
531
|
+
quote-based routes reject before the 402 dance even starts without it.
|
|
508
532
|
"""
|
|
509
533
|
memo = payload.get("memo")
|
|
510
534
|
if memo is not None:
|
|
@@ -535,8 +559,15 @@ class OneShotClient:
|
|
|
535
559
|
await self.aensure_budgets_synced()
|
|
536
560
|
|
|
537
561
|
async with httpx.AsyncClient(timeout=httpx.Timeout(120.0)) as client:
|
|
538
|
-
# Step 1 — Initial POST (expect 402 for paid tools)
|
|
539
|
-
|
|
562
|
+
# Step 1 — Initial POST (expect 402 for paid tools). Quote-based
|
|
563
|
+
# routes (e.g. /email/send) reject with 400 quote_id_required
|
|
564
|
+
# before they ever get to the 402 dance unless x-quote-id rides
|
|
565
|
+
# on this very first attempt, so an already-obtained quote_id
|
|
566
|
+
# must be sent from the start, not just on the paid retry.
|
|
567
|
+
initial_headers = self._headers(max_cost=max_cost)
|
|
568
|
+
if quote_id:
|
|
569
|
+
initial_headers["x-quote-id"] = quote_id
|
|
570
|
+
resp = await client.post(url, headers=initial_headers, json=payload)
|
|
540
571
|
|
|
541
572
|
# The spend-budget gate rejects at quote time (403) so nothing is
|
|
542
573
|
# signed; surface it as the typed error.
|
|
@@ -592,7 +623,12 @@ class OneShotClient:
|
|
|
592
623
|
quote_data = resp.json()
|
|
593
624
|
payment_request = quote_data["payment_request"]
|
|
594
625
|
context = quote_data.get("context", {})
|
|
595
|
-
|
|
626
|
+
# Self-quoting tools (voice/sms/browser/compute) mint a fresh
|
|
627
|
+
# quote_id in this same 402's context; quote-then-send tools
|
|
628
|
+
# (email/send) already had one supplied by the caller and don't
|
|
629
|
+
# get one echoed back here — keep it rather than clobbering it
|
|
630
|
+
# with None, or the paid retry below loses x-quote-id entirely.
|
|
631
|
+
quote_id = context.get("quote_id") or quote_id
|
|
596
632
|
|
|
597
633
|
total = context.get("total") or context.get("pricing", {}).get("total")
|
|
598
634
|
if max_cost is not None and total is not None:
|
|
@@ -727,7 +763,7 @@ class OneShotClient:
|
|
|
727
763
|
) -> Any:
|
|
728
764
|
"""POST to a free endpoint (async)."""
|
|
729
765
|
url = f"{self.base_url}{endpoint}"
|
|
730
|
-
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
|
|
766
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(150.0 if endpoint.startswith("/v1/tools/browser/profiles") else 30.0)) as client:
|
|
731
767
|
resp = await client.post(url, headers=self._read_headers(), json=payload or {})
|
|
732
768
|
if not resp.is_success:
|
|
733
769
|
raise ToolError(f"POST {endpoint} failed", resp.status_code, resp.text)
|
|
@@ -1047,6 +1083,10 @@ class OneShotClient:
|
|
|
1047
1083
|
timeout_sec: int = 300,
|
|
1048
1084
|
) -> Any:
|
|
1049
1085
|
"""Run a browser automation task. Async."""
|
|
1086
|
+
if secrets is not None:
|
|
1087
|
+
raise ValueError("Browser secrets are unsupported; import cookies or storage_state into a profile")
|
|
1088
|
+
if max_steps is not None and (type(max_steps) is not int or not 25 <= max_steps <= 100):
|
|
1089
|
+
raise ValueError("max_steps must be an integer between 25 and 100")
|
|
1050
1090
|
payload: dict[str, Any] = {"task": task}
|
|
1051
1091
|
if output_schema is not None:
|
|
1052
1092
|
payload["output_schema"] = output_schema
|
|
@@ -1056,21 +1096,53 @@ class OneShotClient:
|
|
|
1056
1096
|
payload["allowed_domains"] = allowed_domains
|
|
1057
1097
|
if profile_id is not None:
|
|
1058
1098
|
payload["profile_id"] = profile_id
|
|
1059
|
-
if secrets is not None:
|
|
1060
|
-
payload["secrets"] = secrets
|
|
1061
1099
|
if max_steps is not None:
|
|
1062
1100
|
payload["max_steps"] = max_steps
|
|
1063
1101
|
return await self.acall_tool(
|
|
1064
1102
|
"/v1/tools/browser", payload, max_cost=max_cost, timeout_sec=timeout_sec,
|
|
1065
1103
|
)
|
|
1066
1104
|
|
|
1067
|
-
def create_browser_profile(self, name: str) -> dict:
|
|
1105
|
+
def create_browser_profile(self, name: str, *, cookies: Optional[list] = None, storage_state: Optional[dict] = None) -> dict:
|
|
1068
1106
|
"""Create a persistent browser profile. Blocking."""
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1107
|
+
if cookies is not None and storage_state is not None:
|
|
1108
|
+
raise ValueError("Provide cookies or storage_state, not both")
|
|
1109
|
+
payload = {"name": name}
|
|
1110
|
+
if cookies is not None:
|
|
1111
|
+
payload["cookies"] = cookies
|
|
1112
|
+
if storage_state is not None:
|
|
1113
|
+
payload["storage_state"] = storage_state
|
|
1114
|
+
return self.call_free_post("/v1/tools/browser/profiles", payload)
|
|
1115
|
+
|
|
1116
|
+
async def acreate_browser_profile(self, name: str, *, cookies: Optional[list] = None, storage_state: Optional[dict] = None) -> dict:
|
|
1072
1117
|
"""Create a persistent browser profile. Async."""
|
|
1073
|
-
|
|
1118
|
+
if cookies is not None and storage_state is not None:
|
|
1119
|
+
raise ValueError("Provide cookies or storage_state, not both")
|
|
1120
|
+
payload = {"name": name}
|
|
1121
|
+
if cookies is not None:
|
|
1122
|
+
payload["cookies"] = cookies
|
|
1123
|
+
if storage_state is not None:
|
|
1124
|
+
payload["storage_state"] = storage_state
|
|
1125
|
+
return await self.acall_free_post("/v1/tools/browser/profiles", payload)
|
|
1126
|
+
|
|
1127
|
+
def start_browser_profile_setup(self, profile_id: str, start_url: str) -> dict:
|
|
1128
|
+
"""Open login/2FA live view. Do not log the returned live_url."""
|
|
1129
|
+
return self.call_free_post(f"/v1/tools/browser/profiles/{quote(profile_id, safe='')}/setup/start", {"start_url": start_url})
|
|
1130
|
+
|
|
1131
|
+
async def astart_browser_profile_setup(self, profile_id: str, start_url: str) -> dict:
|
|
1132
|
+
return await self.acall_free_post(f"/v1/tools/browser/profiles/{quote(profile_id, safe='')}/setup/start", {"start_url": start_url})
|
|
1133
|
+
|
|
1134
|
+
def get_browser_profile_setup(self, profile_id: str) -> dict:
|
|
1135
|
+
return self.call_free_post(f"/v1/tools/browser/profiles/{quote(profile_id, safe='')}/setup/status", {})
|
|
1136
|
+
|
|
1137
|
+
async def aget_browser_profile_setup(self, profile_id: str) -> dict:
|
|
1138
|
+
return await self.acall_free_post(f"/v1/tools/browser/profiles/{quote(profile_id, safe='')}/setup/status", {})
|
|
1139
|
+
|
|
1140
|
+
def finish_browser_profile_setup(self, profile_id: str) -> dict:
|
|
1141
|
+
"""Save after human login/2FA; inspect saved cookies before navigation."""
|
|
1142
|
+
return self.call_free_post(f"/v1/tools/browser/profiles/{quote(profile_id, safe='')}/setup/finish", {})
|
|
1143
|
+
|
|
1144
|
+
async def afinish_browser_profile_setup(self, profile_id: str) -> dict:
|
|
1145
|
+
return await self.acall_free_post(f"/v1/tools/browser/profiles/{quote(profile_id, safe='')}/setup/finish", {})
|
|
1074
1146
|
|
|
1075
1147
|
def list_browser_profiles(self) -> list:
|
|
1076
1148
|
"""List all browser profiles. Blocking."""
|
|
@@ -1155,9 +1227,13 @@ class OneShotClient:
|
|
|
1155
1227
|
``mailbox_mode == 'mailbox'``, and read ``mailbox_provisioning_fee`` on
|
|
1156
1228
|
the quote for the exact amount before paying.
|
|
1157
1229
|
"""
|
|
1158
|
-
return
|
|
1159
|
-
|
|
1160
|
-
|
|
1230
|
+
return asyncio.get_event_loop().run_until_complete(
|
|
1231
|
+
self.aemail(
|
|
1232
|
+
to, subject, body,
|
|
1233
|
+
from_domain=from_domain, from_mailbox=from_mailbox, from_name=from_name,
|
|
1234
|
+
reply_to_email_id=reply_to_email_id, mailbox_mode=mailbox_mode,
|
|
1235
|
+
**kwargs,
|
|
1236
|
+
)
|
|
1161
1237
|
)
|
|
1162
1238
|
|
|
1163
1239
|
async def aemail(
|
|
@@ -1181,12 +1257,53 @@ class OneShotClient:
|
|
|
1181
1257
|
|
|
1182
1258
|
``mailbox_mode`` ('relay' | 'mailbox', default 'relay') selects how a
|
|
1183
1259
|
domain sends — see ``email()`` for details.
|
|
1260
|
+
|
|
1261
|
+
Quotes before sending — mirrors the TS SDK (``index.ts`` ``email()``).
|
|
1262
|
+
``/v1/tools/email/send`` requires an ``x-quote-id`` from a prior
|
|
1263
|
+
``/v1/tools/email/quote`` call and 400s without one, and rotation mode
|
|
1264
|
+
(both ``from_domain``/``from_mailbox`` omitted) is decided at quote
|
|
1265
|
+
time and locked server-side for the matching send — so quoting first
|
|
1266
|
+
is not optional, it's the only way rotation (or any send) works.
|
|
1184
1267
|
"""
|
|
1268
|
+
# max_cost/timeout_sec are transport options for acall_tool, not
|
|
1269
|
+
# send-body fields — pull them out of kwargs before they reach
|
|
1270
|
+
# _build_email_payload, or they'd ride along in the JSON body (which
|
|
1271
|
+
# the server's Zod schema silently drops) instead of driving the
|
|
1272
|
+
# X-Max-Cost-USDC header + local budget check and the poll timeout.
|
|
1273
|
+
max_cost = kwargs.pop("max_cost", None)
|
|
1274
|
+
timeout_sec = kwargs.pop("timeout_sec", 120)
|
|
1275
|
+
|
|
1276
|
+
# Quote payload mirrors the TS SDK's quote body: no from_name/extra
|
|
1277
|
+
# kwargs (attachments, idempotencyKey, etc. are send-only concerns).
|
|
1278
|
+
quote_payload = _build_email_payload(
|
|
1279
|
+
to, subject, body, from_domain, from_mailbox, None, {},
|
|
1280
|
+
reply_to_email_id=reply_to_email_id, mailbox_mode=mailbox_mode,
|
|
1281
|
+
)
|
|
1282
|
+
quote = await self.acall_tool(
|
|
1283
|
+
"/v1/tools/email/quote", quote_payload, max_cost=max_cost,
|
|
1284
|
+
)
|
|
1285
|
+
quote_id = quote.get("quote_id") if isinstance(quote, dict) else None
|
|
1286
|
+
quoted_from_address = quote.get("from_address") if isinstance(quote, dict) else None
|
|
1287
|
+
|
|
1288
|
+
send_payload = _build_email_payload(
|
|
1289
|
+
to, subject, body, from_domain, from_mailbox, from_name, kwargs,
|
|
1290
|
+
reply_to_email_id=reply_to_email_id, mailbox_mode=mailbox_mode,
|
|
1291
|
+
)
|
|
1292
|
+
# Replay the quote-locked/rotated address explicitly. The server
|
|
1293
|
+
# already replays it from the quote row when from_address is absent,
|
|
1294
|
+
# but sending it back is harmless and mirrors the TS SDK
|
|
1295
|
+
# (index.ts:425,431) so a future /send-without-quote revision keeps
|
|
1296
|
+
# working unchanged.
|
|
1297
|
+
resolved_from_address = send_payload.get("from_address") or quoted_from_address
|
|
1298
|
+
if resolved_from_address:
|
|
1299
|
+
send_payload["from_address"] = resolved_from_address
|
|
1300
|
+
|
|
1185
1301
|
return await self.acall_tool(
|
|
1186
|
-
"/v1/tools/email/send",
|
|
1187
|
-
|
|
1302
|
+
"/v1/tools/email/send", send_payload, quote_id=quote_id,
|
|
1303
|
+
max_cost=max_cost, timeout_sec=timeout_sec,
|
|
1188
1304
|
)
|
|
1189
1305
|
|
|
1306
|
+
|
|
1190
1307
|
def voice(self, objective: str, target_number: str, **kwargs: Any) -> Any:
|
|
1191
1308
|
"""Make an AI voice call. Blocking."""
|
|
1192
1309
|
return self.call_tool("/v1/tools/voice/call", {"objective": objective, "target_number": target_number, **kwargs})
|
|
@@ -1235,6 +1352,30 @@ class OneShotClient:
|
|
|
1235
1352
|
"""Enrich a company from domain, name, LinkedIn URL, or ticker. Async."""
|
|
1236
1353
|
return await self.acall_tool("/v1/tools/enrich/company", kwargs)
|
|
1237
1354
|
|
|
1355
|
+
def local_search(self, **kwargs: Any) -> Any:
|
|
1356
|
+
"""Discover local businesses (restaurants, contractors, practices) by category/keywords x location. Blocking."""
|
|
1357
|
+
return self.call_tool("/v1/tools/local/search", kwargs)
|
|
1358
|
+
|
|
1359
|
+
async def alocal_search(self, **kwargs: Any) -> Any:
|
|
1360
|
+
"""Discover local businesses (restaurants, contractors, practices) by category/keywords x location. Async."""
|
|
1361
|
+
return await self.acall_tool("/v1/tools/local/search", kwargs)
|
|
1362
|
+
|
|
1363
|
+
def local_resolve(self, name: str, **kwargs: Any) -> Any:
|
|
1364
|
+
"""Resolve a business name + one locating field (address/city/postal_code/phone) to its domain, phone, status. Blocking."""
|
|
1365
|
+
return self.call_tool("/v1/tools/local/resolve", {"name": name, **kwargs})
|
|
1366
|
+
|
|
1367
|
+
async def alocal_resolve(self, name: str, **kwargs: Any) -> Any:
|
|
1368
|
+
"""Resolve a business name + one locating field (address/city/postal_code/phone) to its domain, phone, status. Async."""
|
|
1369
|
+
return await self.acall_tool("/v1/tools/local/resolve", {"name": name, **kwargs})
|
|
1370
|
+
|
|
1371
|
+
def gov_solicitations(self, naics: list[str], **kwargs: Any) -> Any:
|
|
1372
|
+
"""Federal Sources Sought / Presolicitation notices (SAM.gov) by NAICS code with the contracting officer's contact. Result carries `data_as_of` (snapshot behind the rows). Blocking."""
|
|
1373
|
+
return self.call_tool("/v1/tools/gov/solicitations", {"naics": naics, **kwargs})
|
|
1374
|
+
|
|
1375
|
+
async def agov_solicitations(self, naics: list[str], **kwargs: Any) -> Any:
|
|
1376
|
+
"""Federal Sources Sought / Presolicitation notices (SAM.gov) by NAICS code with the contracting officer's contact. Result carries `data_as_of` (snapshot behind the rows). Async."""
|
|
1377
|
+
return await self.acall_tool("/v1/tools/gov/solicitations", {"naics": naics, **kwargs})
|
|
1378
|
+
|
|
1238
1379
|
def find_email(self, company_domain: str, *, full_name: Optional[str] = None, first_name: Optional[str] = None, last_name: Optional[str] = None, **kwargs: Any) -> Any:
|
|
1239
1380
|
"""Find a person's email address. Blocking."""
|
|
1240
1381
|
payload: dict[str, Any] = {"company_domain": company_domain, **kwargs}
|
|
@@ -1527,6 +1668,7 @@ class OneShotClient:
|
|
|
1527
1668
|
f"Job failed: {job.get('error', 'Unknown')}",
|
|
1528
1669
|
request_id,
|
|
1529
1670
|
str(job.get("error", "Unknown")),
|
|
1671
|
+
code=job.get("error_code"), partial_result=job.get("result"),
|
|
1530
1672
|
)
|
|
1531
1673
|
|
|
1532
1674
|
retries = 0
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Physical mail: explicit approval, durable recovery, independent fulfillment."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from typing import Any, Literal, TypedDict
|
|
4
|
+
import math
|
|
5
|
+
import httpx
|
|
6
|
+
from oneshot.x402 import sign_read_proof, parse_payment_required, sign_payment_authorization, encode_payment_header
|
|
7
|
+
from oneshot._errors import OneShotError
|
|
8
|
+
|
|
9
|
+
class _PostalAddressRequired(TypedDict):
|
|
10
|
+
name: str
|
|
11
|
+
address_line1: str
|
|
12
|
+
address_city: str
|
|
13
|
+
address_state: str
|
|
14
|
+
address_zip: str
|
|
15
|
+
|
|
16
|
+
class PostalAddress(_PostalAddressRequired, total=False):
|
|
17
|
+
address_line2: str
|
|
18
|
+
address_country: Literal['US']
|
|
19
|
+
|
|
20
|
+
class MailQuote(TypedDict):
|
|
21
|
+
quote_id: str
|
|
22
|
+
input_hash: str
|
|
23
|
+
status: Literal['rendering', 'ready']
|
|
24
|
+
total_usdc: str | None
|
|
25
|
+
expires_at: str
|
|
26
|
+
preview: dict[str, Any]
|
|
27
|
+
input: dict[str, Any]
|
|
28
|
+
service_fee_usdc: str | None
|
|
29
|
+
approval_id: str | None
|
|
30
|
+
delivery_proves_readership: Literal[False]
|
|
31
|
+
|
|
32
|
+
class MailOrder(TypedDict):
|
|
33
|
+
order_id: str
|
|
34
|
+
quote_id: str
|
|
35
|
+
receipt_id: str
|
|
36
|
+
order_status: str
|
|
37
|
+
payment_status: str
|
|
38
|
+
fulfillment_status: str
|
|
39
|
+
total_usdc: str
|
|
40
|
+
cancellation_requested: bool
|
|
41
|
+
cancellation_error: str | None
|
|
42
|
+
cancel_before: str | None
|
|
43
|
+
refunded_at: str | None
|
|
44
|
+
signed_receipt: Any
|
|
45
|
+
events: list[dict[str, Any]]
|
|
46
|
+
idempotency_key: str
|
|
47
|
+
delivery_proves_readership: Literal[False]
|
|
48
|
+
|
|
49
|
+
class PhysicalMail:
|
|
50
|
+
def __init__(self, client: Any):
|
|
51
|
+
self.client = client
|
|
52
|
+
|
|
53
|
+
async def _request(self, path: str, method: str = 'POST', body: Any = None, *, mime: str | None = None, key: str | None = None, max_cost: float | None = None) -> Any:
|
|
54
|
+
c = self.client
|
|
55
|
+
scope = 'read' if method == 'GET' else 'submit' if path == '/send' else 'write'
|
|
56
|
+
def headers() -> dict[str, str]:
|
|
57
|
+
h = c._headers(max_cost=max_cost)
|
|
58
|
+
h['x-agent-proof'] = sign_read_proof(c._private_key, c.address, scope=scope)
|
|
59
|
+
if key:
|
|
60
|
+
h['Idempotency-Key'] = key
|
|
61
|
+
if mime:
|
|
62
|
+
h['Content-Type'] = mime
|
|
63
|
+
return h
|
|
64
|
+
if path == '/send':
|
|
65
|
+
await c.aensure_budgets_synced()
|
|
66
|
+
async with httpx.AsyncClient(timeout=60.0) as transport:
|
|
67
|
+
kwargs = {'content': body} if mime else {'json': body}
|
|
68
|
+
url = f'{c.base_url}/v1/tools/physical-mail{path}'
|
|
69
|
+
response = await transport.request(method, url, headers=headers(), **kwargs)
|
|
70
|
+
if response.status_code == 402 and path == '/send':
|
|
71
|
+
parsed = parse_payment_required(response.headers.get('payment-required'))
|
|
72
|
+
accepted = parsed['accepted']
|
|
73
|
+
payment = response.json()['payment_request']
|
|
74
|
+
charge = str(int(accepted['amount']) / 1_000_000)
|
|
75
|
+
c._assert_within_budget(charge)
|
|
76
|
+
if max_cost is not None and float(charge) > max_cost:
|
|
77
|
+
raise OneShotError('Physical mail quote exceeds max_cost')
|
|
78
|
+
auth = sign_payment_authorization(private_key=c._private_key, from_address=c.address,
|
|
79
|
+
to_address=payment['recipient'], amount=charge, token_address=payment['token_address'],
|
|
80
|
+
chain_id=payment['chain_id'], network=accepted['network'], accepted=accepted,
|
|
81
|
+
resource=parsed.get('resource'), extensions=parsed.get('extensions'))
|
|
82
|
+
h = headers() # Proof nonces are single use, including the unpaid leg.
|
|
83
|
+
h['payment-signature'] = encode_payment_header(auth)
|
|
84
|
+
response = await transport.post(url, headers=h, json=body)
|
|
85
|
+
response.raise_for_status()
|
|
86
|
+
return response.json()
|
|
87
|
+
|
|
88
|
+
async def aupload_artwork(self, data: bytes, mime: str) -> dict[str, str]:
|
|
89
|
+
return await self._request('/assets', body=data, mime=mime)
|
|
90
|
+
|
|
91
|
+
async def avalidate_address(self, address: PostalAddress) -> dict[str, Any]:
|
|
92
|
+
return await self._request('/validate-address', body=address)
|
|
93
|
+
|
|
94
|
+
async def apreview(self, mail_input: dict[str, Any]) -> MailQuote:
|
|
95
|
+
return await self._request('/preview', body=mail_input)
|
|
96
|
+
|
|
97
|
+
async def aget_quote(self, quote_id: str) -> MailQuote:
|
|
98
|
+
from urllib.parse import quote
|
|
99
|
+
return await self._request(f'/quotes/{quote(quote_id, safe="")}', 'GET')
|
|
100
|
+
|
|
101
|
+
async def aapprove(self, *, quote_id: str, input_hash: str, total_usdc: str, approved: Literal[True]) -> dict[str, str]:
|
|
102
|
+
if approved is not True:
|
|
103
|
+
raise ValueError('Explicit mailpiece approval required')
|
|
104
|
+
return await self._request('/approve', body=dict(quote_id=quote_id, input_hash=input_hash, total_usdc=total_usdc, approved=True))
|
|
105
|
+
|
|
106
|
+
async def asend(self, *, quote_id: str, approval_id: str, idempotency_key: str, max_cost: float | None = None, memo: str | None = None, decision_context: dict[str, Any] | None = None) -> MailOrder:
|
|
107
|
+
if max_cost is not None and (not math.isfinite(max_cost) or max_cost <= 0):
|
|
108
|
+
raise ValueError('Physical mail max_cost must be finite and positive')
|
|
109
|
+
if not idempotency_key or not approval_id:
|
|
110
|
+
raise ValueError('Persist an idempotency key and explicitly approve the quote before sending')
|
|
111
|
+
body: dict[str, Any] = dict(quote_id=quote_id, approval_id=approval_id)
|
|
112
|
+
if memo is not None:
|
|
113
|
+
body['memo'] = memo
|
|
114
|
+
if decision_context is not None:
|
|
115
|
+
body['decisionContext'] = decision_context
|
|
116
|
+
try:
|
|
117
|
+
return await self._request('/send', body=body, key=idempotency_key, max_cost=max_cost)
|
|
118
|
+
except Exception as error:
|
|
119
|
+
error.idempotency_key = idempotency_key # type: ignore[attr-defined]
|
|
120
|
+
raise
|
|
121
|
+
|
|
122
|
+
async def aget_order(self, order_id: str) -> MailOrder:
|
|
123
|
+
from urllib.parse import quote
|
|
124
|
+
return await self._request(f'/orders/{quote(order_id, safe="")}', 'GET')
|
|
125
|
+
|
|
126
|
+
async def arecover(self, key: str) -> MailOrder:
|
|
127
|
+
from urllib.parse import quote
|
|
128
|
+
return await self._request(f'/orders/recover?key={quote(key, safe="")}', 'GET')
|
|
129
|
+
|
|
130
|
+
async def acancel(self, order_id: str) -> MailOrder:
|
|
131
|
+
from urllib.parse import quote
|
|
132
|
+
return await self._request(f'/orders/{quote(order_id, safe="")}/cancel', body={})
|
|
133
|
+
|
|
134
|
+
def upload_artwork(self, *args: Any, **kwargs: Any) -> Any:
|
|
135
|
+
import asyncio
|
|
136
|
+
return asyncio.get_event_loop().run_until_complete(self.aupload_artwork(*args, **kwargs))
|
|
137
|
+
|
|
138
|
+
def validate_address(self, *args: Any, **kwargs: Any) -> Any:
|
|
139
|
+
import asyncio
|
|
140
|
+
return asyncio.get_event_loop().run_until_complete(self.avalidate_address(*args, **kwargs))
|
|
141
|
+
|
|
142
|
+
def preview(self, *args: Any, **kwargs: Any) -> Any:
|
|
143
|
+
import asyncio
|
|
144
|
+
return asyncio.get_event_loop().run_until_complete(self.apreview(*args, **kwargs))
|
|
145
|
+
|
|
146
|
+
def get_quote(self, *args: Any, **kwargs: Any) -> Any:
|
|
147
|
+
import asyncio
|
|
148
|
+
return asyncio.get_event_loop().run_until_complete(self.aget_quote(*args, **kwargs))
|
|
149
|
+
|
|
150
|
+
def approve(self, *args: Any, **kwargs: Any) -> Any:
|
|
151
|
+
import asyncio
|
|
152
|
+
return asyncio.get_event_loop().run_until_complete(self.aapprove(*args, **kwargs))
|
|
153
|
+
|
|
154
|
+
def send(self, *args: Any, **kwargs: Any) -> Any:
|
|
155
|
+
import asyncio
|
|
156
|
+
return asyncio.get_event_loop().run_until_complete(self.asend(*args, **kwargs))
|
|
157
|
+
|
|
158
|
+
def get_order(self, *args: Any, **kwargs: Any) -> Any:
|
|
159
|
+
import asyncio
|
|
160
|
+
return asyncio.get_event_loop().run_until_complete(self.aget_order(*args, **kwargs))
|
|
161
|
+
|
|
162
|
+
def recover(self, *args: Any, **kwargs: Any) -> Any:
|
|
163
|
+
import asyncio
|
|
164
|
+
return asyncio.get_event_loop().run_until_complete(self.arecover(*args, **kwargs))
|
|
165
|
+
|
|
166
|
+
def cancel(self, *args: Any, **kwargs: Any) -> Any:
|
|
167
|
+
import asyncio
|
|
168
|
+
return asyncio.get_event_loop().run_until_complete(self.acancel(*args, **kwargs))
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "oneshot-python"
|
|
3
|
-
version = "0.
|
|
4
|
-
description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling"
|
|
3
|
+
version = "0.24.0"
|
|
4
|
+
description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 35 tools"
|
|
5
5
|
readme = {text = "Core Python SDK for the OneShot API", content-type = "text/plain"}
|
|
6
6
|
license = "MIT"
|
|
7
7
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Browser credentials bypass paid task prose; invalid requests fail locally."""
|
|
2
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from oneshot.client import OneShotClient
|
|
6
|
+
from oneshot._errors import JobError
|
|
7
|
+
|
|
8
|
+
TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.asyncio
|
|
12
|
+
@pytest.mark.parametrize('options', [{'secrets': {}}, {'max_steps': 3}, {'max_steps': 25.5}, {'max_steps': True}])
|
|
13
|
+
async def test_invalid_task_never_calls_paid_endpoint(options):
|
|
14
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
15
|
+
client.acall_tool = AsyncMock()
|
|
16
|
+
with pytest.raises(ValueError):
|
|
17
|
+
await client.abrowser('Read heading', **options)
|
|
18
|
+
client.acall_tool.assert_not_awaited()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.mark.asyncio
|
|
22
|
+
async def test_import_uses_profile_endpoint_and_rejects_ambiguous_state():
|
|
23
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
24
|
+
client.acall_free_post = AsyncMock(return_value={'id': 'profile'})
|
|
25
|
+
state = {'cookies': [], 'origins': []}
|
|
26
|
+
assert await client.acreate_browser_profile('test', storage_state=state) == {'id': 'profile'}
|
|
27
|
+
client.acall_free_post.assert_awaited_once_with('/v1/tools/browser/profiles', {'name': 'test', 'storage_state': state})
|
|
28
|
+
with pytest.raises(ValueError):
|
|
29
|
+
await client.acreate_browser_profile('test', cookies=[], storage_state=state)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.asyncio
|
|
33
|
+
async def test_failed_poll_preserves_partial_browser_result():
|
|
34
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
35
|
+
partial = {'cost': .1, 'steps': [{'number': 1, 'goal': 'Browser action', 'url': 'https://example.com'}]}
|
|
36
|
+
response = MagicMock(is_success=True, status_code=200)
|
|
37
|
+
response.json.return_value = {'status': 'failed', 'error_message': 'Browser timeout', 'error_code': 'browser_timeout', 'result': partial}
|
|
38
|
+
http = MagicMock()
|
|
39
|
+
http.get = AsyncMock(return_value=response)
|
|
40
|
+
with pytest.raises(JobError) as caught:
|
|
41
|
+
await client._poll_job(http, 'request', 10)
|
|
42
|
+
assert caught.value.code == 'browser_timeout'
|
|
43
|
+
assert caught.value.partial_result == partial
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.asyncio
|
|
47
|
+
async def test_interactive_setup_uses_free_authenticated_operations():
|
|
48
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
49
|
+
client.acall_free_post = AsyncMock(return_value={'status': 'idle'})
|
|
50
|
+
await client.astart_browser_profile_setup('profile', 'https://example.com/login')
|
|
51
|
+
await client.aget_browser_profile_setup('profile')
|
|
52
|
+
await client.afinish_browser_profile_setup('profile')
|
|
53
|
+
assert [(call.args[0],call.args[1]) for call in client.acall_free_post.await_args_list] == [
|
|
54
|
+
('/v1/tools/browser/profiles/profile/setup/start', {'start_url':'https://example.com/login'}),
|
|
55
|
+
('/v1/tools/browser/profiles/profile/setup/status', {}),
|
|
56
|
+
('/v1/tools/browser/profiles/profile/setup/finish', {}),
|
|
57
|
+
]
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Tests for the email payload construction in OneShotClient.
|
|
2
|
+
|
|
3
|
+
Verifies that email()/aemail() send the API's field convention
|
|
4
|
+
(from_address + to_address), build from_address from from_mailbox/from_domain
|
|
5
|
+
when a domain is pinned, omit from_address entirely to trigger server-side
|
|
6
|
+
rotation when both are omitted, reject a from_mailbox pinned without
|
|
7
|
+
from_domain, and forward the optional from_name display name.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from oneshot._errors import ValidationError
|
|
18
|
+
from oneshot.client import OneShotClient, _build_email_payload
|
|
19
|
+
|
|
20
|
+
TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
|
|
21
|
+
REPLY_ID = "4fa85f64-5717-4562-b3fc-2c963f66afa6"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture(autouse=True)
|
|
25
|
+
def _fresh_event_loop():
|
|
26
|
+
"""Give every test in this module its own event loop.
|
|
27
|
+
|
|
28
|
+
The sync surface (``email()``) bridges to ``aemail()`` via
|
|
29
|
+
``asyncio.get_event_loop().run_until_complete(...)``. pytest-asyncio
|
|
30
|
+
clears the current event loop after each async test, which otherwise
|
|
31
|
+
breaks any *sync* test — mocked or not — that happens to run afterward
|
|
32
|
+
in the same session ("There is no current event loop in thread
|
|
33
|
+
'MainThread'"). Without this fixture the suite's pass/fail would depend
|
|
34
|
+
on test collection order, which is exactly the kind of environment-
|
|
35
|
+
dependent test this correction round was asked to avoid introducing.
|
|
36
|
+
"""
|
|
37
|
+
loop = asyncio.new_event_loop()
|
|
38
|
+
asyncio.set_event_loop(loop)
|
|
39
|
+
yield
|
|
40
|
+
loop.close()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── _build_email_payload (pure) ───────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
def test_payload_omits_from_address_when_no_from_knobs_given():
|
|
46
|
+
# Rotation mode: matches the TS SDK's contract — never fabricate
|
|
47
|
+
# `agent@oneshotagent.com`. The server picks a domain from the agent's
|
|
48
|
+
# own pool and the caller replays whatever the quote returns.
|
|
49
|
+
p = _build_email_payload("r@x.com", "S", "B", None, None, None, {})
|
|
50
|
+
assert p == {
|
|
51
|
+
"to_address": "r@x.com",
|
|
52
|
+
"subject": "S",
|
|
53
|
+
"body": "B",
|
|
54
|
+
}
|
|
55
|
+
assert "from_address" not in p
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_payload_uses_mailbox_domain_and_name():
|
|
59
|
+
p = _build_email_payload("r@x.com", "S", "B", "acme.com", "jane", "Jane Doe", {})
|
|
60
|
+
assert p["from_address"] == "jane@acme.com"
|
|
61
|
+
assert p["from_name"] == "Jane Doe"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_payload_from_domain_alone_defaults_mailbox_to_agent():
|
|
65
|
+
p = _build_email_payload("r@x.com", "S", "B", "acme.com", None, None, {})
|
|
66
|
+
assert p["from_address"] == "agent@acme.com"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_payload_from_mailbox_without_from_domain_raises():
|
|
70
|
+
# No default domain to fall back to — oneshotagent.com is OneShot's own
|
|
71
|
+
# domain and no agent can send from it. This must be a client-side
|
|
72
|
+
# ValidationError naming from_domain, never a fabricated address.
|
|
73
|
+
with pytest.raises(ValidationError, match="from_domain"):
|
|
74
|
+
_build_email_payload("r@x.com", "S", "B", None, "jane", None, {})
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_payload_omits_empty_from_name():
|
|
78
|
+
p = _build_email_payload("r@x.com", "S", "B", None, None, "", {})
|
|
79
|
+
assert "from_name" not in p
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_payload_passes_extra_kwargs():
|
|
83
|
+
p = _build_email_payload("r@x.com", "S", "B", None, None, None, {"wait": True})
|
|
84
|
+
assert p["wait"] is True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_payload_forwards_mailbox_mode():
|
|
88
|
+
# mailbox_mode is an explicit keyword-only param, forwarded into the send body so
|
|
89
|
+
# the API's per-domain provider override + fee logic sees it.
|
|
90
|
+
p = _build_email_payload("r@x.com", "S", "B", "acme.com", None, None, {}, mailbox_mode="mailbox")
|
|
91
|
+
assert p["mailbox_mode"] == "mailbox"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_payload_omits_mailbox_mode_when_absent():
|
|
95
|
+
p = _build_email_payload("r@x.com", "S", "B", "acme.com", None, None, {})
|
|
96
|
+
assert "mailbox_mode" not in p
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── reply threading (reply_to_email_id) ───────────────────────────────────
|
|
100
|
+
|
|
101
|
+
def test_reply_payload_omits_to_and_subject_when_derivable():
|
|
102
|
+
# Replying: to/subject may be omitted; server derives them.
|
|
103
|
+
p = _build_email_payload(None, None, "B", None, None, None, {}, reply_to_email_id=REPLY_ID)
|
|
104
|
+
assert p["reply_to_email_id"] == REPLY_ID
|
|
105
|
+
assert p["body"] == "B"
|
|
106
|
+
assert "to_address" not in p
|
|
107
|
+
assert "subject" not in p
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_reply_payload_keeps_explicit_to_and_subject():
|
|
111
|
+
p = _build_email_payload("r@x.com", "Custom", "B", None, None, None, {}, reply_to_email_id=REPLY_ID)
|
|
112
|
+
assert p["reply_to_email_id"] == REPLY_ID
|
|
113
|
+
assert p["to_address"] == "r@x.com"
|
|
114
|
+
assert p["subject"] == "Custom"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_non_reply_requires_to():
|
|
118
|
+
with pytest.raises(ValidationError):
|
|
119
|
+
_build_email_payload(None, "S", "B", None, None, None, {})
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_non_reply_requires_subject():
|
|
123
|
+
with pytest.raises(ValidationError):
|
|
124
|
+
_build_email_payload("r@x.com", None, "B", None, None, None, {})
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_reply_does_not_require_to_or_subject():
|
|
128
|
+
# Should not raise even though to and subject are None.
|
|
129
|
+
_build_email_payload(None, None, "B", None, None, None, {}, reply_to_email_id=REPLY_ID)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── email()/aemail() quote-then-send flow ──────────────────────────────────
|
|
133
|
+
#
|
|
134
|
+
# email()/aemail() now quote before sending (mirrors the TS SDK's
|
|
135
|
+
# email/quote -> email/send flow in libs/agent-sdk/src/index.ts). These tests
|
|
136
|
+
# stub acall_tool itself (the real HTTP/x402 transport) so they exercise the
|
|
137
|
+
# actual two-request flow end-to-end, instead of stopping at the payload
|
|
138
|
+
# builder — the gap that let a Python send hard-fail with 400
|
|
139
|
+
# missing_from_address in production while the mocked-call_tool tests still
|
|
140
|
+
# passed.
|
|
141
|
+
|
|
142
|
+
def _capturing_client(
|
|
143
|
+
quote_from_address: str | None = None,
|
|
144
|
+
quote_extra: dict | None = None,
|
|
145
|
+
) -> tuple[OneShotClient, list[dict]]:
|
|
146
|
+
"""A client whose acall_tool is stubbed to capture both the /email/quote
|
|
147
|
+
and /email/send calls it makes, without touching the network."""
|
|
148
|
+
c = OneShotClient(TEST_PRIVATE_KEY)
|
|
149
|
+
calls: list[dict] = []
|
|
150
|
+
|
|
151
|
+
async def fake_acall_tool(endpoint, payload, **kwargs):
|
|
152
|
+
calls.append({
|
|
153
|
+
"endpoint": endpoint,
|
|
154
|
+
"payload": dict(payload),
|
|
155
|
+
"quote_id": kwargs.get("quote_id"),
|
|
156
|
+
"max_cost": kwargs.get("max_cost"),
|
|
157
|
+
"timeout_sec": kwargs.get("timeout_sec"),
|
|
158
|
+
})
|
|
159
|
+
if endpoint.endswith("/email/quote"):
|
|
160
|
+
resp: dict[str, Any] = {"total_cost": "0.01", "quote_id": "quote_test_1"}
|
|
161
|
+
if quote_from_address:
|
|
162
|
+
resp["from_address"] = quote_from_address
|
|
163
|
+
if quote_extra:
|
|
164
|
+
resp.update(quote_extra)
|
|
165
|
+
return resp
|
|
166
|
+
return {"request_id": "r1", "status": "completed"}
|
|
167
|
+
|
|
168
|
+
c.acall_tool = fake_acall_tool # type: ignore[method-assign]
|
|
169
|
+
return c, calls
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _find(calls: list[dict], suffix: str) -> dict:
|
|
173
|
+
for call in calls:
|
|
174
|
+
if call["endpoint"].endswith(suffix):
|
|
175
|
+
return call
|
|
176
|
+
raise AssertionError(f"no call to endpoint ending in {suffix!r} among {[c['endpoint'] for c in calls]}")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@pytest.mark.asyncio
|
|
180
|
+
async def test_aemail_rotation_quotes_before_send_and_replays_picked_address():
|
|
181
|
+
# Rotation mode (no from_domain/from_mailbox): from_address must be
|
|
182
|
+
# omitted on the quote so the server picks a domain from the agent's own
|
|
183
|
+
# pool, and the picked address must be replayed on /send — otherwise
|
|
184
|
+
# /send 400s with missing_from_address (apps/api-service's
|
|
185
|
+
# /email/send resolves from_address only from an explicit param, a
|
|
186
|
+
# quote-locked address, or a reply target).
|
|
187
|
+
c, calls = _capturing_client(quote_from_address="agent@picked.com")
|
|
188
|
+
await c.aemail("r@x.com", "Hi", "Body")
|
|
189
|
+
|
|
190
|
+
quote = _find(calls, "/email/quote")
|
|
191
|
+
send = _find(calls, "/email/send")
|
|
192
|
+
assert "from_address" not in quote["payload"]
|
|
193
|
+
assert send["payload"]["from_address"] == "agent@picked.com"
|
|
194
|
+
assert send["payload"]["to_address"] == "r@x.com"
|
|
195
|
+
# /email/send requires x-quote-id from the very first attempt (the API
|
|
196
|
+
# 400s quote_id_required / "Quote ID required" otherwise) — acall_tool
|
|
197
|
+
# must receive the quote's id so it can set the header on every attempt.
|
|
198
|
+
assert send["quote_id"] == "quote_test_1"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_email_sends_correct_contract():
|
|
202
|
+
c, calls = _capturing_client()
|
|
203
|
+
c.email("r@x.com", "Hi", "Body", from_mailbox="jane", from_domain="acme.com", from_name="Jane Doe")
|
|
204
|
+
quote = _find(calls, "/email/quote")
|
|
205
|
+
send = _find(calls, "/email/send")
|
|
206
|
+
assert quote["payload"]["from_address"] == "jane@acme.com"
|
|
207
|
+
assert send["payload"]["from_address"] == "jane@acme.com"
|
|
208
|
+
assert send["payload"]["to_address"] == "r@x.com"
|
|
209
|
+
assert send["payload"]["from_name"] == "Jane Doe"
|
|
210
|
+
# from_name is a display-only field for the send, not part of pricing —
|
|
211
|
+
# mirrors the TS SDK quote body, which never sends it either.
|
|
212
|
+
assert "from_name" not in quote["payload"]
|
|
213
|
+
assert send["quote_id"] == "quote_test_1"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_email_reply_forwards_reply_id_without_to_subject():
|
|
217
|
+
c, calls = _capturing_client(quote_from_address="agent@picked.com")
|
|
218
|
+
c.email(reply_to_email_id=REPLY_ID, body="Thanks!")
|
|
219
|
+
quote = _find(calls, "/email/quote")
|
|
220
|
+
send = _find(calls, "/email/send")
|
|
221
|
+
for call in (quote, send):
|
|
222
|
+
assert call["payload"]["reply_to_email_id"] == REPLY_ID
|
|
223
|
+
assert call["payload"]["body"] == "Thanks!"
|
|
224
|
+
assert "to_address" not in call["payload"]
|
|
225
|
+
assert "subject" not in call["payload"]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_email_without_to_or_reply_id_raises():
|
|
229
|
+
c, calls = _capturing_client()
|
|
230
|
+
with pytest.raises(ValidationError):
|
|
231
|
+
c.email(body="orphan body")
|
|
232
|
+
assert calls == [] # fails before any network call — quote or send
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_email_from_mailbox_without_from_domain_raises():
|
|
236
|
+
# Public email() surface, not just the pure _build_email_payload helper:
|
|
237
|
+
# pinning from_mailbox alone must fail client-side naming from_domain,
|
|
238
|
+
# never send agent@oneshotagent.com — checked before quoting, so no
|
|
239
|
+
# network call is made at all.
|
|
240
|
+
c, calls = _capturing_client()
|
|
241
|
+
with pytest.raises(ValidationError, match="from_domain"):
|
|
242
|
+
c.email("r@x.com", "Hi", "Body", from_mailbox="jane")
|
|
243
|
+
assert calls == []
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ── max_cost / timeout_sec forwarding (finding PRRT_kwDOQhndvM6gpNlg) ──────
|
|
247
|
+
#
|
|
248
|
+
# aemail() takes max_cost/timeout_sec via **kwargs alongside genuine send-body
|
|
249
|
+
# extras (attachments, idempotencyKey, ...). Before this fix they fell straight
|
|
250
|
+
# through to _build_email_payload and rode along in the JSON body — never
|
|
251
|
+
# reaching acall_tool, so the X-Max-Cost-USDC header + local budget check never
|
|
252
|
+
# fired on the quote or send call, and the poll used the 120s default no
|
|
253
|
+
# matter what the caller asked for.
|
|
254
|
+
|
|
255
|
+
@pytest.mark.asyncio
|
|
256
|
+
async def test_aemail_forwards_max_cost_and_timeout_to_transport_not_body():
|
|
257
|
+
c, calls = _capturing_client(quote_from_address="agent@picked.com")
|
|
258
|
+
await c.aemail("r@x.com", "Hi", "Body", max_cost=0.05, timeout_sec=30)
|
|
259
|
+
|
|
260
|
+
quote = _find(calls, "/email/quote")
|
|
261
|
+
send = _find(calls, "/email/send")
|
|
262
|
+
# Reached acall_tool as transport options...
|
|
263
|
+
assert quote["max_cost"] == 0.05
|
|
264
|
+
assert send["max_cost"] == 0.05
|
|
265
|
+
assert send["timeout_sec"] == 30
|
|
266
|
+
# ...and did NOT leak into the JSON payload either call sends.
|
|
267
|
+
assert "max_cost" not in quote["payload"]
|
|
268
|
+
assert "max_cost" not in send["payload"]
|
|
269
|
+
assert "timeout_sec" not in quote["payload"]
|
|
270
|
+
assert "timeout_sec" not in send["payload"]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@pytest.mark.asyncio
|
|
274
|
+
async def test_aemail_default_timeout_sec_is_120_when_unset():
|
|
275
|
+
c, calls = _capturing_client(quote_from_address="agent@picked.com")
|
|
276
|
+
await c.aemail("r@x.com", "Hi", "Body")
|
|
277
|
+
|
|
278
|
+
send = _find(calls, "/email/send")
|
|
279
|
+
assert send["timeout_sec"] == 120
|
|
280
|
+
quote = _find(calls, "/email/quote")
|
|
281
|
+
assert quote["max_cost"] is None
|
|
282
|
+
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
3
|
+
from oneshot.physical_mail import PhysicalMail
|
|
4
|
+
|
|
5
|
+
@pytest.mark.asyncio
|
|
6
|
+
async def test_preview_and_approval_do_not_send():
|
|
7
|
+
mail = PhysicalMail(MagicMock())
|
|
8
|
+
mail._request = AsyncMock(return_value={})
|
|
9
|
+
await mail.apreview({'artwork': {'kind': 'letter', 'file': 'asset'}})
|
|
10
|
+
assert mail._request.call_args.args[0] == '/preview'
|
|
11
|
+
with pytest.raises(ValueError):
|
|
12
|
+
await mail.aapprove(quote_id='q', input_hash='h', total_usdc='1.05', approved=False)
|
|
13
|
+
await mail.aapprove(quote_id='q', input_hash='h', total_usdc='1.05', approved=True)
|
|
14
|
+
assert mail._request.call_args.args[0] == '/approve'
|
|
15
|
+
|
|
16
|
+
@pytest.mark.asyncio
|
|
17
|
+
async def test_send_preserves_key_and_approval_and_recovery_does_not_send():
|
|
18
|
+
mail = PhysicalMail(MagicMock())
|
|
19
|
+
mail._request = AsyncMock(return_value={})
|
|
20
|
+
with pytest.raises(ValueError):
|
|
21
|
+
await mail.asend(quote_id='q', approval_id='a', idempotency_key='')
|
|
22
|
+
await mail.asend(quote_id='q', approval_id='a', idempotency_key='durable', max_cost=1.05)
|
|
23
|
+
assert mail._request.call_args.kwargs['key'] == 'durable'
|
|
24
|
+
assert mail._request.call_args.kwargs['body'] == {'quote_id': 'q', 'approval_id': 'a'}
|
|
25
|
+
await mail.arecover('a/b')
|
|
26
|
+
assert mail._request.call_args.args == ('/orders/recover?key=a%2Fb', 'GET')
|
|
27
|
+
|
|
28
|
+
@pytest.mark.asyncio
|
|
29
|
+
async def test_transport_error_carries_recovery_key():
|
|
30
|
+
mail = PhysicalMail(MagicMock())
|
|
31
|
+
mail._request = AsyncMock(side_effect=TimeoutError())
|
|
32
|
+
with pytest.raises(TimeoutError) as caught:
|
|
33
|
+
await mail.asend(quote_id='q', approval_id='a', idempotency_key='persisted')
|
|
34
|
+
assert caught.value.idempotency_key == 'persisted'
|
|
35
|
+
|
|
36
|
+
@pytest.mark.asyncio
|
|
37
|
+
@pytest.mark.parametrize('max_cost', [0, -1, float('nan'), float('inf')])
|
|
38
|
+
async def test_invalid_caps_never_submit(max_cost):
|
|
39
|
+
mail = PhysicalMail(MagicMock())
|
|
40
|
+
mail._request = AsyncMock()
|
|
41
|
+
with pytest.raises(ValueError, match='finite and positive'):
|
|
42
|
+
await mail.asend(quote_id='q', approval_id='a', idempotency_key='key', max_cost=max_cost)
|
|
43
|
+
mail._request.assert_not_called()
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
"""Tests for the email payload construction in OneShotClient.
|
|
2
|
-
|
|
3
|
-
Verifies that email()/aemail() send the API's field convention
|
|
4
|
-
(from_address + to_address), build from_address from from_mailbox/from_domain,
|
|
5
|
-
and forward the optional from_name display name.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
from unittest.mock import AsyncMock, MagicMock
|
|
11
|
-
|
|
12
|
-
import pytest
|
|
13
|
-
|
|
14
|
-
from oneshot._errors import ValidationError
|
|
15
|
-
from oneshot.client import OneShotClient, _build_email_payload
|
|
16
|
-
|
|
17
|
-
TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
|
|
18
|
-
REPLY_ID = "4fa85f64-5717-4562-b3fc-2c963f66afa6"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
# ── _build_email_payload (pure) ───────────────────────────────────────────
|
|
22
|
-
|
|
23
|
-
def test_payload_defaults_to_agent_mailbox():
|
|
24
|
-
p = _build_email_payload("r@x.com", "S", "B", None, None, None, {})
|
|
25
|
-
assert p == {
|
|
26
|
-
"from_address": "agent@oneshotagent.com",
|
|
27
|
-
"to_address": "r@x.com",
|
|
28
|
-
"subject": "S",
|
|
29
|
-
"body": "B",
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def test_payload_uses_mailbox_domain_and_name():
|
|
34
|
-
p = _build_email_payload("r@x.com", "S", "B", "acme.com", "jane", "Jane Doe", {})
|
|
35
|
-
assert p["from_address"] == "jane@acme.com"
|
|
36
|
-
assert p["from_name"] == "Jane Doe"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def test_payload_omits_empty_from_name():
|
|
40
|
-
p = _build_email_payload("r@x.com", "S", "B", None, None, "", {})
|
|
41
|
-
assert "from_name" not in p
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def test_payload_passes_extra_kwargs():
|
|
45
|
-
p = _build_email_payload("r@x.com", "S", "B", None, None, None, {"wait": True})
|
|
46
|
-
assert p["wait"] is True
|
|
47
|
-
|
|
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
|
-
|
|
61
|
-
# ── reply threading (reply_to_email_id) ───────────────────────────────────
|
|
62
|
-
|
|
63
|
-
def test_reply_payload_omits_to_and_subject_when_derivable():
|
|
64
|
-
# Replying: to/subject may be omitted; server derives them.
|
|
65
|
-
p = _build_email_payload(None, None, "B", None, None, None, {}, reply_to_email_id=REPLY_ID)
|
|
66
|
-
assert p["reply_to_email_id"] == REPLY_ID
|
|
67
|
-
assert p["body"] == "B"
|
|
68
|
-
assert "to_address" not in p
|
|
69
|
-
assert "subject" not in p
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def test_reply_payload_keeps_explicit_to_and_subject():
|
|
73
|
-
p = _build_email_payload("r@x.com", "Custom", "B", None, None, None, {}, reply_to_email_id=REPLY_ID)
|
|
74
|
-
assert p["reply_to_email_id"] == REPLY_ID
|
|
75
|
-
assert p["to_address"] == "r@x.com"
|
|
76
|
-
assert p["subject"] == "Custom"
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def test_non_reply_requires_to():
|
|
80
|
-
with pytest.raises(ValidationError):
|
|
81
|
-
_build_email_payload(None, "S", "B", None, None, None, {})
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def test_non_reply_requires_subject():
|
|
85
|
-
with pytest.raises(ValidationError):
|
|
86
|
-
_build_email_payload("r@x.com", None, "B", None, None, None, {})
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def test_reply_does_not_require_to_or_subject():
|
|
90
|
-
# Should not raise even though to and subject are None.
|
|
91
|
-
_build_email_payload(None, None, "B", None, None, None, {}, reply_to_email_id=REPLY_ID)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
# ── email() delegation ────────────────────────────────────────────────────
|
|
95
|
-
|
|
96
|
-
def _client() -> OneShotClient:
|
|
97
|
-
c = OneShotClient(TEST_PRIVATE_KEY)
|
|
98
|
-
c.call_tool = MagicMock(return_value={"request_id": "r1"}) # type: ignore[method-assign]
|
|
99
|
-
return c
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def test_email_sends_correct_contract():
|
|
103
|
-
c = _client()
|
|
104
|
-
c.email("r@x.com", "Hi", "Body", from_mailbox="jane", from_domain="acme.com", from_name="Jane Doe")
|
|
105
|
-
endpoint, payload = c.call_tool.call_args[0]
|
|
106
|
-
assert endpoint == "/v1/tools/email/send"
|
|
107
|
-
assert payload["from_address"] == "jane@acme.com"
|
|
108
|
-
assert payload["to_address"] == "r@x.com"
|
|
109
|
-
assert payload["from_name"] == "Jane Doe"
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
@pytest.mark.asyncio
|
|
113
|
-
async def test_aemail_sends_correct_contract():
|
|
114
|
-
c = OneShotClient(TEST_PRIVATE_KEY)
|
|
115
|
-
c.acall_tool = AsyncMock(return_value={"request_id": "r2"}) # type: ignore[method-assign]
|
|
116
|
-
await c.aemail("r@x.com", "Hi", "Body")
|
|
117
|
-
endpoint, payload = c.acall_tool.call_args[0]
|
|
118
|
-
assert endpoint == "/v1/tools/email/send"
|
|
119
|
-
assert payload["from_address"] == "agent@oneshotagent.com"
|
|
120
|
-
assert payload["to_address"] == "r@x.com"
|
|
121
|
-
assert "from_name" not in payload
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def test_email_reply_forwards_reply_id_without_to_subject():
|
|
125
|
-
c = _client()
|
|
126
|
-
c.email(reply_to_email_id=REPLY_ID, body="Thanks!")
|
|
127
|
-
endpoint, payload = c.call_tool.call_args[0]
|
|
128
|
-
assert endpoint == "/v1/tools/email/send"
|
|
129
|
-
assert payload["reply_to_email_id"] == REPLY_ID
|
|
130
|
-
assert payload["body"] == "Thanks!"
|
|
131
|
-
assert "to_address" not in payload
|
|
132
|
-
assert "subject" not in payload
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
def test_email_without_to_or_reply_id_raises():
|
|
136
|
-
c = _client()
|
|
137
|
-
with pytest.raises(ValidationError):
|
|
138
|
-
c.email(body="orphan body")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|