replylayer 0.16.0__tar.gz → 0.17.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.
- {replylayer-0.16.0 → replylayer-0.17.0}/PKG-INFO +16 -3
- {replylayer-0.16.0 → replylayer-0.17.0}/README.md +15 -2
- {replylayer-0.16.0 → replylayer-0.17.0}/pyproject.toml +1 -1
- {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/__init__.py +1 -1
- {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/_http.py +1 -1
- {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/attachments.py +3 -2
- {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/drafts.py +16 -4
- {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/threads.py +8 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/types.py +3 -2
- {replylayer-0.16.0 → replylayer-0.17.0}/tests/test_drafts.py +63 -3
- replylayer-0.17.0/tests/test_threads.py +96 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/.gitignore +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/__main__.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/_client.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/_pagination.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/errors.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/py.typed +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/__init__.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/account.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/api_keys.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/domains.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/health.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/inbound_blocklist.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/legal_holds.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/mailboxes.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/messages.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/recipients.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/suppressions.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/webhooks.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/tests/__init__.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/tests/test_async.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/tests/test_attachments.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/tests/test_client.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/tests/test_domains.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/tests/test_hitl_review_types.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/tests/test_http.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/tests/test_resources.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/tests/test_version.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/tests/test_web_risk_types.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/tests/test_webhooks.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/tests/test_ws1_ws6.py +0 -0
- {replylayer-0.16.0 → replylayer-0.17.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: replylayer
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.17.0
|
|
4
4
|
Summary: Official Python SDK for ReplyLayer — email for AI agents
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Keywords: agent,ai,email,mailbox,replylayer,sdk,webhook
|
|
@@ -167,7 +167,7 @@ except ReplyLayerError as err:
|
|
|
167
167
|
|
|
168
168
|
## Outbound attachments (Pro+)
|
|
169
169
|
|
|
170
|
-
Attaching a file is a **two-phase** flow: upload the bytes to stage a handle, then reference `handle["id"]` in a send/reply/draft `attachment_ids` list. Every attachment is scanned (byte-level family validation + AV + secrets/PII over extracted text **and** filename) before it leaves. The mailbox must have outbound attachments **explicitly enabled**
|
|
170
|
+
Attaching a file is a **two-phase** flow: upload the bytes to stage a handle, then reference `handle["id"]` in a send/reply/draft `attachment_ids` list. Every attachment is scanned (byte-level family validation + AV + secrets/PII over extracted text **and** filename) before it leaves. The mailbox must have outbound attachments **explicitly enabled** by a human account owner in the dashboard (Pro+, mailbox Settings page, TOTP/password re-auth). Once enabled, API keys can send attachments; uploads to a non-enabled mailbox raise `ForbiddenError` with `code="OUTBOUND_ATTACHMENTS_DISABLED"`.
|
|
171
171
|
|
|
172
172
|
```python
|
|
173
173
|
import time
|
|
@@ -470,7 +470,7 @@ Error classes: `ReplyLayerError` (base), `AuthenticationError` (401), `Forbidden
|
|
|
470
470
|
|
|
471
471
|
## Webhook signature verification
|
|
472
472
|
|
|
473
|
-
> For a full integration guide (event catalog, retry behavior, idempotency, security, troubleshooting), see
|
|
473
|
+
> For a full integration guide (event catalog, retry behavior, idempotency, security, troubleshooting), see the hosted webhook docs (coming).
|
|
474
474
|
|
|
475
475
|
```python
|
|
476
476
|
from replylayer import verify_webhook_signature
|
|
@@ -483,6 +483,19 @@ verify_webhook_signature(
|
|
|
483
483
|
)
|
|
484
484
|
```
|
|
485
485
|
|
|
486
|
+
Once verified, parse and dispatch on the event type. **The discriminator field is `event`, not `type`:**
|
|
487
|
+
|
|
488
|
+
```python
|
|
489
|
+
import json
|
|
490
|
+
|
|
491
|
+
payload = json.loads(request.body)
|
|
492
|
+
# payload["event"] is the discriminator — NOT payload["type"]
|
|
493
|
+
if payload["event"] == "message.received":
|
|
494
|
+
# handle inbound message
|
|
495
|
+
elif payload["event"] == "message.dispatch_failed":
|
|
496
|
+
# handle failed outbound send
|
|
497
|
+
```
|
|
498
|
+
|
|
486
499
|
## Context managers
|
|
487
500
|
|
|
488
501
|
Both clients support context managers to properly close connection pools:
|
|
@@ -150,7 +150,7 @@ except ReplyLayerError as err:
|
|
|
150
150
|
|
|
151
151
|
## Outbound attachments (Pro+)
|
|
152
152
|
|
|
153
|
-
Attaching a file is a **two-phase** flow: upload the bytes to stage a handle, then reference `handle["id"]` in a send/reply/draft `attachment_ids` list. Every attachment is scanned (byte-level family validation + AV + secrets/PII over extracted text **and** filename) before it leaves. The mailbox must have outbound attachments **explicitly enabled**
|
|
153
|
+
Attaching a file is a **two-phase** flow: upload the bytes to stage a handle, then reference `handle["id"]` in a send/reply/draft `attachment_ids` list. Every attachment is scanned (byte-level family validation + AV + secrets/PII over extracted text **and** filename) before it leaves. The mailbox must have outbound attachments **explicitly enabled** by a human account owner in the dashboard (Pro+, mailbox Settings page, TOTP/password re-auth). Once enabled, API keys can send attachments; uploads to a non-enabled mailbox raise `ForbiddenError` with `code="OUTBOUND_ATTACHMENTS_DISABLED"`.
|
|
154
154
|
|
|
155
155
|
```python
|
|
156
156
|
import time
|
|
@@ -453,7 +453,7 @@ Error classes: `ReplyLayerError` (base), `AuthenticationError` (401), `Forbidden
|
|
|
453
453
|
|
|
454
454
|
## Webhook signature verification
|
|
455
455
|
|
|
456
|
-
> For a full integration guide (event catalog, retry behavior, idempotency, security, troubleshooting), see
|
|
456
|
+
> For a full integration guide (event catalog, retry behavior, idempotency, security, troubleshooting), see the hosted webhook docs (coming).
|
|
457
457
|
|
|
458
458
|
```python
|
|
459
459
|
from replylayer import verify_webhook_signature
|
|
@@ -466,6 +466,19 @@ verify_webhook_signature(
|
|
|
466
466
|
)
|
|
467
467
|
```
|
|
468
468
|
|
|
469
|
+
Once verified, parse and dispatch on the event type. **The discriminator field is `event`, not `type`:**
|
|
470
|
+
|
|
471
|
+
```python
|
|
472
|
+
import json
|
|
473
|
+
|
|
474
|
+
payload = json.loads(request.body)
|
|
475
|
+
# payload["event"] is the discriminator — NOT payload["type"]
|
|
476
|
+
if payload["event"] == "message.received":
|
|
477
|
+
# handle inbound message
|
|
478
|
+
elif payload["event"] == "message.dispatch_failed":
|
|
479
|
+
# handle failed outbound send
|
|
480
|
+
```
|
|
481
|
+
|
|
469
482
|
## Context managers
|
|
470
483
|
|
|
471
484
|
Both clients support context managers to properly close connection pools:
|
|
@@ -51,8 +51,9 @@ class SyncAttachments:
|
|
|
51
51
|
content_type: str | None = None,
|
|
52
52
|
) -> UploadAttachmentResponse:
|
|
53
53
|
"""Stage an outbound attachment (phase 1). Returns an opaque handle;
|
|
54
|
-
pass ``handle["id"]`` in a send/reply/draft ``attachment_ids`` list.
|
|
55
|
-
|
|
54
|
+
pass ``handle["id"]`` in a send/reply/draft ``attachment_ids`` list. A
|
|
55
|
+
human account owner must first enable outbound attachments for the
|
|
56
|
+
mailbox in the dashboard (Pro+, TOTP/password re-auth). The returned
|
|
56
57
|
``content_scan_status`` is ``"pending"`` — poll :meth:`get_upload` until
|
|
57
58
|
terminal before referencing the handle, or the send fails with
|
|
58
59
|
``ATTACHMENT_SCAN_PENDING``.
|
|
@@ -146,8 +146,14 @@ class SyncDrafts:
|
|
|
146
146
|
q = {**query, "before": cursor or query["before"]}
|
|
147
147
|
res = self._http.request("GET", f"/v1/mailboxes/{mailbox_id}/drafts", query=q)
|
|
148
148
|
drafts = res.get("drafts", [])
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
# UAT-22 — consume the server's authoritative `has_more` (#296) instead
|
|
150
|
+
# of the old `len(drafts) == limit` inference, which over-reported at
|
|
151
|
+
# the exact-`limit` boundary. Derive `next_cursor` FROM has_more so we
|
|
152
|
+
# only advance when the server says another page exists. Defensive
|
|
153
|
+
# default `False` for a pre-#296 server that omits the field.
|
|
154
|
+
has_more = res.get("has_more") is True
|
|
155
|
+
next_cursor = drafts[-1]["id"] if has_more and drafts else None
|
|
156
|
+
return Page(data=drafts, has_more=has_more, cursor=next_cursor)
|
|
151
157
|
|
|
152
158
|
if auto_paginate:
|
|
153
159
|
return sync_auto_paginate(fetch_page)
|
|
@@ -299,8 +305,14 @@ class AsyncDrafts:
|
|
|
299
305
|
q = {**query, "before": cursor or query["before"]}
|
|
300
306
|
res = await self._http.request("GET", f"/v1/mailboxes/{mailbox_id}/drafts", query=q)
|
|
301
307
|
drafts = res.get("drafts", [])
|
|
302
|
-
|
|
303
|
-
|
|
308
|
+
# UAT-22 — consume the server's authoritative `has_more` (#296) instead
|
|
309
|
+
# of the old `len(drafts) == limit` inference (byte-identical to the
|
|
310
|
+
# sync method). A sync-only fix would leave this async path still
|
|
311
|
+
# over-reporting at the exact-`limit` boundary. Defensive default
|
|
312
|
+
# `False` for a pre-#296 server that omits the field.
|
|
313
|
+
has_more = res.get("has_more") is True
|
|
314
|
+
next_cursor = drafts[-1]["id"] if has_more and drafts else None
|
|
315
|
+
return Page(data=drafts, has_more=has_more, cursor=next_cursor)
|
|
304
316
|
|
|
305
317
|
if auto_paginate:
|
|
306
318
|
return async_auto_paginate(fetch_page)
|
|
@@ -24,6 +24,7 @@ class SyncThreads:
|
|
|
24
24
|
has_inbound: bool | None = None,
|
|
25
25
|
include_firewall_blocked: bool = False,
|
|
26
26
|
view: str | None = None,
|
|
27
|
+
starred: bool | None = None,
|
|
27
28
|
auto_paginate: bool = False,
|
|
28
29
|
) -> Union[Page, Iterator[dict[str, Any]]]:
|
|
29
30
|
def fetch_page(cursor: str | None) -> Page:
|
|
@@ -34,6 +35,9 @@ class SyncThreads:
|
|
|
34
35
|
"has_inbound": ("true" if has_inbound else "false") if has_inbound is not None else None,
|
|
35
36
|
"include_firewall_blocked": "true" if include_firewall_blocked else None,
|
|
36
37
|
"view": view,
|
|
38
|
+
# UAT-04 (F04 residual) — filter starred/unstarred threads. Mirrors
|
|
39
|
+
# the messages.list idiom; omitted when None so no param is sent.
|
|
40
|
+
"starred": str(starred).lower() if starred is not None else None,
|
|
37
41
|
}
|
|
38
42
|
res = self._http.request("GET", f"/v1/mailboxes/{mailbox_id}/threads", query=query)
|
|
39
43
|
threads = res.get("threads", [])
|
|
@@ -115,6 +119,7 @@ class AsyncThreads:
|
|
|
115
119
|
has_inbound: bool | None = None,
|
|
116
120
|
include_firewall_blocked: bool = False,
|
|
117
121
|
view: str | None = None,
|
|
122
|
+
starred: bool | None = None,
|
|
118
123
|
auto_paginate: bool = False,
|
|
119
124
|
) -> Page | AsyncIterator[dict[str, Any]]:
|
|
120
125
|
async def fetch_page(cursor: str | None) -> Page:
|
|
@@ -125,6 +130,9 @@ class AsyncThreads:
|
|
|
125
130
|
"has_inbound": ("true" if has_inbound else "false") if has_inbound is not None else None,
|
|
126
131
|
"include_firewall_blocked": "true" if include_firewall_blocked else None,
|
|
127
132
|
"view": view,
|
|
133
|
+
# UAT-04 (F04 residual) — filter starred/unstarred threads (async
|
|
134
|
+
# parity with SyncThreads.list). Omitted when None.
|
|
135
|
+
"starred": str(starred).lower() if starred is not None else None,
|
|
128
136
|
}
|
|
129
137
|
res = await self._http.request("GET", f"/v1/mailboxes/{mailbox_id}/threads", query=query)
|
|
130
138
|
threads = res.get("threads", [])
|
|
@@ -1137,8 +1137,9 @@ class CreateDraftRequest(TypedDict, total=False):
|
|
|
1137
1137
|
subaddress_instance_id: str
|
|
1138
1138
|
subaddress_mode: SubaddressMode
|
|
1139
1139
|
# Outbound attachment handles (plans/outbound-attachment-ux.md) — held on
|
|
1140
|
-
# the draft and consumed once at dispatch. Requires
|
|
1141
|
-
# outbound attachments
|
|
1140
|
+
# the draft and consumed once at dispatch. Requires a human account owner
|
|
1141
|
+
# to enable outbound attachments for the mailbox in the dashboard first
|
|
1142
|
+
# (Pro+, TOTP/password re-auth).
|
|
1142
1143
|
attachment_ids: list[str]
|
|
1143
1144
|
|
|
1144
1145
|
|
|
@@ -83,13 +83,71 @@ def test_drafts_list_page():
|
|
|
83
83
|
}
|
|
84
84
|
for i in range(50)
|
|
85
85
|
]
|
|
86
|
-
|
|
86
|
+
# UAT-22 — full page AND the server signals more rows exist.
|
|
87
|
+
respx.get(f"{BASE}/v1/mailboxes/mbx1/drafts").mock(
|
|
88
|
+
return_value=httpx.Response(200, json={"drafts": drafts, "has_more": True}),
|
|
89
|
+
)
|
|
87
90
|
page = sdk().drafts.list("mbx1")
|
|
88
91
|
assert len(page.data) == 50
|
|
89
92
|
assert page.has_more is True
|
|
90
93
|
assert page.cursor == "d49"
|
|
91
94
|
|
|
92
95
|
|
|
96
|
+
# ── UAT-22 — both sync AND async `list` must read the server's `has_more`
|
|
97
|
+
# instead of inferring it from `len(drafts) == limit`. The buggy inference
|
|
98
|
+
# over-reported at the exact-`limit` boundary (a full page with no further
|
|
99
|
+
# rows looked like "more"), costing an extra empty round-trip. ──
|
|
100
|
+
|
|
101
|
+
@respx.mock
|
|
102
|
+
def test_drafts_list_sync_exact_limit_boundary_follows_server_has_more():
|
|
103
|
+
# Exactly `limit` rows, but the server says there are no more. The old
|
|
104
|
+
# `len(drafts) == limit` inference would falsely report has_more=True and a
|
|
105
|
+
# non-null cursor here. Assert the SYNC list now follows the server field.
|
|
106
|
+
drafts = [
|
|
107
|
+
{
|
|
108
|
+
"id": f"d{i}", "mailbox_id": "mbx1", "state": "draft",
|
|
109
|
+
"sender": "bot@mail.replylayer.ai", "recipient": "r@example.com",
|
|
110
|
+
"subject": "t", "worst_decision": "allow",
|
|
111
|
+
"created_at": "2026-04-17T00:00:00Z", "updated_at": "2026-04-17T00:00:00Z",
|
|
112
|
+
}
|
|
113
|
+
for i in range(2)
|
|
114
|
+
]
|
|
115
|
+
route = respx.get(f"{BASE}/v1/mailboxes/mbx1/drafts").mock(
|
|
116
|
+
return_value=httpx.Response(200, json={"drafts": drafts, "has_more": False}),
|
|
117
|
+
)
|
|
118
|
+
page = sdk().drafts.list("mbx1", limit=2) # request exactly `limit` rows
|
|
119
|
+
# Request-capture: confirm the limit went out on the wire.
|
|
120
|
+
assert route.calls.last.request.url.params.get("limit") == "2"
|
|
121
|
+
assert len(page.data) == 2 # exactly the requested limit
|
|
122
|
+
assert page.has_more is False # follows the server field, NOT len == limit
|
|
123
|
+
assert page.cursor is None # cursor derived from has_more → no next page
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@pytest.mark.asyncio
|
|
127
|
+
@respx.mock
|
|
128
|
+
async def test_drafts_list_async_exact_limit_boundary_follows_server_has_more():
|
|
129
|
+
# Async parity for the boundary case — a sync-only fix would leave this
|
|
130
|
+
# over-reporting at the exact-`limit` boundary.
|
|
131
|
+
drafts = [
|
|
132
|
+
{
|
|
133
|
+
"id": f"d{i}", "mailbox_id": "mbx1", "state": "draft",
|
|
134
|
+
"sender": "bot@mail.replylayer.ai", "recipient": "r@example.com",
|
|
135
|
+
"subject": "t", "worst_decision": "allow",
|
|
136
|
+
"created_at": "2026-04-17T00:00:00Z", "updated_at": "2026-04-17T00:00:00Z",
|
|
137
|
+
}
|
|
138
|
+
for i in range(2)
|
|
139
|
+
]
|
|
140
|
+
route = respx.get(f"{BASE}/v1/mailboxes/mbx1/drafts").mock(
|
|
141
|
+
return_value=httpx.Response(200, json={"drafts": drafts, "has_more": False}),
|
|
142
|
+
)
|
|
143
|
+
async with async_sdk() as client:
|
|
144
|
+
page = await client.drafts.list("mbx1", limit=2)
|
|
145
|
+
assert route.calls.last.request.url.params.get("limit") == "2"
|
|
146
|
+
assert len(page.data) == 2
|
|
147
|
+
assert page.has_more is False # async path also reads the server field
|
|
148
|
+
assert page.cursor is None
|
|
149
|
+
|
|
150
|
+
|
|
93
151
|
@respx.mock
|
|
94
152
|
def test_drafts_list_auto_paginate():
|
|
95
153
|
page1 = [
|
|
@@ -108,9 +166,11 @@ def test_drafts_list_auto_paginate():
|
|
|
108
166
|
"created_at": "2026-04-17T00:00:00Z", "updated_at": "2026-04-17T00:00:00Z",
|
|
109
167
|
}]
|
|
110
168
|
route = respx.get(f"{BASE}/v1/mailboxes/mbx1/drafts")
|
|
169
|
+
# UAT-22 — page1 signals more (has_more=True) so auto-paginate advances;
|
|
170
|
+
# page2 closes the iteration (has_more=False → no cursor → stop).
|
|
111
171
|
route.side_effect = [
|
|
112
|
-
httpx.Response(200, json={"drafts": page1}),
|
|
113
|
-
httpx.Response(200, json={"drafts": page2}),
|
|
172
|
+
httpx.Response(200, json={"drafts": page1, "has_more": True}),
|
|
173
|
+
httpx.Response(200, json={"drafts": page2, "has_more": False}),
|
|
114
174
|
]
|
|
115
175
|
ids = [d["id"] for d in sdk().drafts.list("mbx1", limit=2, auto_paginate=True)]
|
|
116
176
|
assert ids == ["d0", "d1", "d2"]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Python SDK threads resource tests.
|
|
3
|
+
|
|
4
|
+
UAT-04 (F04 residual) — `threads.list()` gained the `starred` kwarg so it has
|
|
5
|
+
parity with `messages.list()` and the TS SDK. These tests assert the param is
|
|
6
|
+
forwarded on the wire when supplied and omitted when not, for BOTH the sync and
|
|
7
|
+
async list methods. Mirror the request-capture pattern in test_drafts.py /
|
|
8
|
+
test_resources.py.
|
|
9
|
+
"""
|
|
10
|
+
import httpx
|
|
11
|
+
import pytest
|
|
12
|
+
import respx
|
|
13
|
+
|
|
14
|
+
from replylayer import AsyncReplyLayer, ReplyLayer
|
|
15
|
+
|
|
16
|
+
BASE = "https://api.test.replylayer.ai"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def sdk() -> ReplyLayer:
|
|
20
|
+
return ReplyLayer(api_key="rl_live_test", base_url=BASE, max_retries=0)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def async_sdk() -> AsyncReplyLayer:
|
|
24
|
+
return AsyncReplyLayer(api_key="rl_live_test", base_url=BASE, max_retries=0)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ── Sync ──
|
|
28
|
+
|
|
29
|
+
@respx.mock
|
|
30
|
+
def test_threads_list_forwards_starred_true():
|
|
31
|
+
route = respx.get(f"{BASE}/v1/mailboxes/mbx1/threads").mock(
|
|
32
|
+
return_value=httpx.Response(200, json={"threads": []}),
|
|
33
|
+
)
|
|
34
|
+
sdk().threads.list("mbx1", starred=True)
|
|
35
|
+
params = route.calls.last.request.url.params
|
|
36
|
+
assert params["starred"] == "true"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@respx.mock
|
|
40
|
+
def test_threads_list_forwards_starred_false():
|
|
41
|
+
route = respx.get(f"{BASE}/v1/mailboxes/mbx1/threads").mock(
|
|
42
|
+
return_value=httpx.Response(200, json={"threads": []}),
|
|
43
|
+
)
|
|
44
|
+
sdk().threads.list("mbx1", starred=False)
|
|
45
|
+
params = route.calls.last.request.url.params
|
|
46
|
+
# str(False).lower() == "false" — distinct from the omitted case below.
|
|
47
|
+
assert params["starred"] == "false"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@respx.mock
|
|
51
|
+
def test_threads_list_omits_starred_when_unset():
|
|
52
|
+
route = respx.get(f"{BASE}/v1/mailboxes/mbx1/threads").mock(
|
|
53
|
+
return_value=httpx.Response(200, json={"threads": []}),
|
|
54
|
+
)
|
|
55
|
+
sdk().threads.list("mbx1")
|
|
56
|
+
params = route.calls.last.request.url.params
|
|
57
|
+
assert "starred" not in params
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── Async parity — a sync-only fix would leave the async list without the
|
|
61
|
+
# kwarg / param. ──
|
|
62
|
+
|
|
63
|
+
@pytest.mark.asyncio
|
|
64
|
+
@respx.mock
|
|
65
|
+
async def test_async_threads_list_forwards_starred_true():
|
|
66
|
+
route = respx.get(f"{BASE}/v1/mailboxes/mbx1/threads").mock(
|
|
67
|
+
return_value=httpx.Response(200, json={"threads": []}),
|
|
68
|
+
)
|
|
69
|
+
async with async_sdk() as client:
|
|
70
|
+
await client.threads.list("mbx1", starred=True)
|
|
71
|
+
params = route.calls.last.request.url.params
|
|
72
|
+
assert params["starred"] == "true"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.asyncio
|
|
76
|
+
@respx.mock
|
|
77
|
+
async def test_async_threads_list_forwards_starred_false():
|
|
78
|
+
route = respx.get(f"{BASE}/v1/mailboxes/mbx1/threads").mock(
|
|
79
|
+
return_value=httpx.Response(200, json={"threads": []}),
|
|
80
|
+
)
|
|
81
|
+
async with async_sdk() as client:
|
|
82
|
+
await client.threads.list("mbx1", starred=False)
|
|
83
|
+
params = route.calls.last.request.url.params
|
|
84
|
+
assert params["starred"] == "false"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.mark.asyncio
|
|
88
|
+
@respx.mock
|
|
89
|
+
async def test_async_threads_list_omits_starred_when_unset():
|
|
90
|
+
route = respx.get(f"{BASE}/v1/mailboxes/mbx1/threads").mock(
|
|
91
|
+
return_value=httpx.Response(200, json={"threads": []}),
|
|
92
|
+
)
|
|
93
|
+
async with async_sdk() as client:
|
|
94
|
+
await client.threads.list("mbx1")
|
|
95
|
+
params = route.calls.last.request.url.params
|
|
96
|
+
assert "starred" not in params
|
|
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
|
|
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
|