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.
Files changed (42) hide show
  1. {replylayer-0.16.0 → replylayer-0.17.0}/PKG-INFO +16 -3
  2. {replylayer-0.16.0 → replylayer-0.17.0}/README.md +15 -2
  3. {replylayer-0.16.0 → replylayer-0.17.0}/pyproject.toml +1 -1
  4. {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/__init__.py +1 -1
  5. {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/_http.py +1 -1
  6. {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/attachments.py +3 -2
  7. {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/drafts.py +16 -4
  8. {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/threads.py +8 -0
  9. {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/types.py +3 -2
  10. {replylayer-0.16.0 → replylayer-0.17.0}/tests/test_drafts.py +63 -3
  11. replylayer-0.17.0/tests/test_threads.py +96 -0
  12. {replylayer-0.16.0 → replylayer-0.17.0}/.gitignore +0 -0
  13. {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/__main__.py +0 -0
  14. {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/_client.py +0 -0
  15. {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/_pagination.py +0 -0
  16. {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/errors.py +0 -0
  17. {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/py.typed +0 -0
  18. {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/__init__.py +0 -0
  19. {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/account.py +0 -0
  20. {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/api_keys.py +0 -0
  21. {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/domains.py +0 -0
  22. {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/health.py +0 -0
  23. {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/inbound_blocklist.py +0 -0
  24. {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/legal_holds.py +0 -0
  25. {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/mailboxes.py +0 -0
  26. {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/messages.py +0 -0
  27. {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/recipients.py +0 -0
  28. {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/suppressions.py +0 -0
  29. {replylayer-0.16.0 → replylayer-0.17.0}/replylayer/resources/webhooks.py +0 -0
  30. {replylayer-0.16.0 → replylayer-0.17.0}/tests/__init__.py +0 -0
  31. {replylayer-0.16.0 → replylayer-0.17.0}/tests/test_async.py +0 -0
  32. {replylayer-0.16.0 → replylayer-0.17.0}/tests/test_attachments.py +0 -0
  33. {replylayer-0.16.0 → replylayer-0.17.0}/tests/test_client.py +0 -0
  34. {replylayer-0.16.0 → replylayer-0.17.0}/tests/test_domains.py +0 -0
  35. {replylayer-0.16.0 → replylayer-0.17.0}/tests/test_hitl_review_types.py +0 -0
  36. {replylayer-0.16.0 → replylayer-0.17.0}/tests/test_http.py +0 -0
  37. {replylayer-0.16.0 → replylayer-0.17.0}/tests/test_resources.py +0 -0
  38. {replylayer-0.16.0 → replylayer-0.17.0}/tests/test_version.py +0 -0
  39. {replylayer-0.16.0 → replylayer-0.17.0}/tests/test_web_risk_types.py +0 -0
  40. {replylayer-0.16.0 → replylayer-0.17.0}/tests/test_webhooks.py +0 -0
  41. {replylayer-0.16.0 → replylayer-0.17.0}/tests/test_ws1_ws6.py +0 -0
  42. {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.16.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** (a Pro+, session-gated dashboard action) — uploads to a non-enabled mailbox raise `ForbiddenError` with `code="OUTBOUND_ATTACHMENTS_DISABLED"`.
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 [`docs/webhooks.md`](../../docs/webhooks.md).
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** (a Pro+, session-gated dashboard action) — uploads to a non-enabled mailbox raise `ForbiddenError` with `code="OUTBOUND_ATTACHMENTS_DISABLED"`.
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 [`docs/webhooks.md`](../../docs/webhooks.md).
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:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "replylayer"
7
- version = "0.16.0"
7
+ version = "0.17.0"
8
8
  description = "Official Python SDK for ReplyLayer — email for AI agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -76,7 +76,7 @@ from .types import (
76
76
  ScannerPolicy,
77
77
  )
78
78
 
79
- __version__ = "0.16.0"
79
+ __version__ = "0.17.0"
80
80
 
81
81
  __all__ = [
82
82
  # WS1 — star response types (0.16.0).
@@ -9,7 +9,7 @@ import httpx
9
9
 
10
10
  from .errors import ReplyLayerError, error_from_response
11
11
 
12
- _VERSION = "0.16.0"
12
+ _VERSION = "0.17.0"
13
13
  _USER_AGENT = f"replylayer-sdk-py/{_VERSION}"
14
14
  _PROTECTED_HEADER_KEYS = frozenset({"authorization", "content-type", "user-agent"})
15
15
 
@@ -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. The
55
- mailbox must have outbound attachments enabled (Pro+). The returned
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
- next_cursor = drafts[-1]["id"] if len(drafts) == limit else None
150
- return Page(data=drafts, has_more=len(drafts) == limit, cursor=next_cursor)
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
- next_cursor = drafts[-1]["id"] if len(drafts) == limit else None
303
- return Page(data=drafts, has_more=len(drafts) == limit, cursor=next_cursor)
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 the mailbox to have
1141
- # outbound attachments enabled (Pro+).
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
- respx.get(f"{BASE}/v1/mailboxes/mbx1/drafts").mock(return_value=httpx.Response(200, json={"drafts": drafts}))
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