replylayer 0.14.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.14.0/README.md → replylayer-0.17.0/PKG-INFO +37 -5
  2. replylayer-0.14.0/PKG-INFO → replylayer-0.17.0/README.md +20 -20
  3. {replylayer-0.14.0 → replylayer-0.17.0}/pyproject.toml +6 -1
  4. {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/__init__.py +11 -1
  5. replylayer-0.17.0/replylayer/__main__.py +25 -0
  6. {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/_http.py +1 -1
  7. {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/attachments.py +3 -2
  8. {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/drafts.py +40 -14
  9. {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/messages.py +34 -1
  10. {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/threads.py +65 -5
  11. {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/types.py +32 -2
  12. {replylayer-0.14.0 → replylayer-0.17.0}/tests/test_drafts.py +63 -3
  13. replylayer-0.17.0/tests/test_threads.py +96 -0
  14. replylayer-0.17.0/tests/test_version.py +18 -0
  15. replylayer-0.17.0/tests/test_ws1_ws6.py +369 -0
  16. {replylayer-0.14.0 → replylayer-0.17.0}/uv.lock +21 -2
  17. {replylayer-0.14.0 → replylayer-0.17.0}/.gitignore +0 -0
  18. {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/_client.py +0 -0
  19. {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/_pagination.py +0 -0
  20. {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/errors.py +0 -0
  21. {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/py.typed +0 -0
  22. {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/__init__.py +0 -0
  23. {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/account.py +0 -0
  24. {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/api_keys.py +0 -0
  25. {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/domains.py +0 -0
  26. {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/health.py +0 -0
  27. {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/inbound_blocklist.py +0 -0
  28. {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/legal_holds.py +0 -0
  29. {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/mailboxes.py +0 -0
  30. {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/recipients.py +0 -0
  31. {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/suppressions.py +0 -0
  32. {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/webhooks.py +0 -0
  33. {replylayer-0.14.0 → replylayer-0.17.0}/tests/__init__.py +0 -0
  34. {replylayer-0.14.0 → replylayer-0.17.0}/tests/test_async.py +0 -0
  35. {replylayer-0.14.0 → replylayer-0.17.0}/tests/test_attachments.py +0 -0
  36. {replylayer-0.14.0 → replylayer-0.17.0}/tests/test_client.py +0 -0
  37. {replylayer-0.14.0 → replylayer-0.17.0}/tests/test_domains.py +0 -0
  38. {replylayer-0.14.0 → replylayer-0.17.0}/tests/test_hitl_review_types.py +0 -0
  39. {replylayer-0.14.0 → replylayer-0.17.0}/tests/test_http.py +0 -0
  40. {replylayer-0.14.0 → replylayer-0.17.0}/tests/test_resources.py +0 -0
  41. {replylayer-0.14.0 → replylayer-0.17.0}/tests/test_web_risk_types.py +0 -0
  42. {replylayer-0.14.0 → replylayer-0.17.0}/tests/test_webhooks.py +0 -0
@@ -1,7 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: replylayer
3
+ Version: 0.17.0
4
+ Summary: Official Python SDK for ReplyLayer — email for AI agents
5
+ License-Expression: MIT
6
+ Keywords: agent,ai,email,mailbox,replylayer,sdk,webhook
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: httpx>=0.27
9
+ Requires-Dist: typing-extensions>=4.0
10
+ Provides-Extra: cli
11
+ Requires-Dist: rly>=0.6.3; extra == 'cli'
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
14
+ Requires-Dist: pytest>=8.0; extra == 'dev'
15
+ Requires-Dist: respx>=0.21; extra == 'dev'
16
+ Description-Content-Type: text/markdown
17
+
1
18
  # replylayer
2
19
 
3
20
  Official Python SDK for [ReplyLayer](https://replylayer.ai) — secure email for AI agents.
4
21
 
22
+ > **Looking for the command-line tool?** This package is the SDK *library* (`import replylayer`). For the `rly` / `replylayer` CLI, install [`rly`](https://pypi.org/project/rly/) instead: `pipx install rly`.
23
+
5
24
  ## Install
6
25
 
7
26
  ```bash
@@ -99,9 +118,9 @@ contract — read it before relying on retries:
99
118
  |----------|---------|
100
119
  | `rl.mailboxes` | `create`, `list`, `delete`, `update`, `set_recipient_policy` |
101
120
  | `rl.mailboxes.allowlist` | `list`, `add`, `add_bulk`, `delete`, `list_blocked_attempts` |
102
- | `rl.messages` | `send`, `list`, `get`, `reply`, `wait`, `release`, `block` |
121
+ | `rl.messages` | `send`, `list`, `get`, `reply`, `wait`, `release`, `block`, `set_starred` |
103
122
  | `rl.drafts` | `create`, `get`, `list`, `update`, `send`, `delete` |
104
- | `rl.threads` | `list`, `get` |
123
+ | `rl.threads` | `list`, `get`, `set_starred` |
105
124
  | `rl.attachments` | `get_download_url`, `get_preview`, `upload`, `get_upload`, `delete_upload` |
106
125
  | `rl.webhooks` | `create`, `list`, `get`, `update`, `delete`, `rotate_secret`, `test`, `list_deliveries`, `retry_delivery` |
107
126
  | `rl.recipients` | `create`, `list`, `delete`, `resend` |
@@ -130,7 +149,7 @@ if draft["worst_decision"] == "allow":
130
149
 
131
150
  The send/reply/draft-send response carries two additive, nullable keys that explain a held send inline (no second `messages.get` call). `result["scan"]` is the vendor-neutral scanner verdict (`ScanSummary`); `result["hold_context"]` (`{"trigger_source", "summary_reasons"}` or `None`) is the policy/HITL reason, non-null only when the delivery `status` diverges from `scan["verdict"]` because of a policy/HITL hold — a clean scan held for review by your mailbox policy, or a scanner review-flag held as quarantine on a plan without the review queue (`trigger_source`: `mailbox_policy` | `scanner` | `both`).
132
151
 
133
- This SDK always sends **synchronously** — `drafts.send()`, `messages.send()`, and `messages.reply()` return only once the scanner verdict is known, with `scan` and `hold_context` inline. The optimistic-ack async path (`Prefer: respond-async` → `202 queued_for_dispatch`, then poll the message to a terminal state) is a REST-level capability of `POST /v1/drafts/:id/send` only; the SDK exposes no `Prefer` option. To use it, drive that route directly (see ENDPOINTS.md "Asynchronous send (optimistic-ack) & polling") and poll `messages.get(message_id)` (or handle the lifecycle webhook) until `state` is terminal. (`messages.wait()` is a mailbox long-poll for new *inbound* mail, not a way to observe a specific message by ID.)
152
+ By default `drafts.send()`, `messages.send()`, and `messages.reply()` return only once the scanner verdict is known, with `scan` and `hold_context` inline. Pass `async_dispatch=True` to `drafts.send()` to send the `Prefer: respond-async` hint. **The hint is advisory** — the server returns a `202 AsyncSendAck` only when `OUTBOUND_ASYNC_DISPATCH_ENABLED` is on; otherwise it ignores the hint and returns a normal `SendMessageResponse`. **Always branch on the result**: `result["status"] == "queued_for_dispatch"` ⇒ `AsyncSendAck`, otherwise `SendMessageResponse`. Poll `messages.get(message_id)` (or handle the lifecycle webhook) until `state` is terminal. Attachment-bearing drafts fail closed on the async path (`400 ATTACHMENTS_REQUIRE_SYNC_SEND`). (`messages.wait()` is a mailbox long-poll for new *inbound* mail, not a way to observe a specific message by ID.)
134
153
 
135
154
  The send endpoint raises `ReplyLayerError` with distinct `.code` values on 409:
136
155
  - `DRAFT_REJECTED_BY_RESCAN` — send-time scan flipped the verdict to `block`/`quarantine`. The draft stays in `draft` state; edit the body and retry. `err.details` carries `scan` and, when a policy/HITL decision drove the hold, `hold_context`.
@@ -148,7 +167,7 @@ except ReplyLayerError as err:
148
167
 
149
168
  ## Outbound attachments (Pro+)
150
169
 
151
- 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"`.
152
171
 
153
172
  ```python
154
173
  import time
@@ -451,7 +470,7 @@ Error classes: `ReplyLayerError` (base), `AuthenticationError` (401), `Forbidden
451
470
 
452
471
  ## Webhook signature verification
453
472
 
454
- > 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).
455
474
 
456
475
  ```python
457
476
  from replylayer import verify_webhook_signature
@@ -464,6 +483,19 @@ verify_webhook_signature(
464
483
  )
465
484
  ```
466
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
+
467
499
  ## Context managers
468
500
 
469
501
  Both clients support context managers to properly close connection pools:
@@ -1,22 +1,9 @@
1
- Metadata-Version: 2.4
2
- Name: replylayer
3
- Version: 0.14.0
4
- Summary: Official Python SDK for ReplyLayer — email for AI agents
5
- License-Expression: MIT
6
- Keywords: agent,ai,email,mailbox,replylayer,sdk,webhook
7
- Requires-Python: >=3.10
8
- Requires-Dist: httpx>=0.27
9
- Requires-Dist: typing-extensions>=4.0
10
- Provides-Extra: dev
11
- Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
12
- Requires-Dist: pytest>=8.0; extra == 'dev'
13
- Requires-Dist: respx>=0.21; extra == 'dev'
14
- Description-Content-Type: text/markdown
15
-
16
1
  # replylayer
17
2
 
18
3
  Official Python SDK for [ReplyLayer](https://replylayer.ai) — secure email for AI agents.
19
4
 
5
+ > **Looking for the command-line tool?** This package is the SDK *library* (`import replylayer`). For the `rly` / `replylayer` CLI, install [`rly`](https://pypi.org/project/rly/) instead: `pipx install rly`.
6
+
20
7
  ## Install
21
8
 
22
9
  ```bash
@@ -114,9 +101,9 @@ contract — read it before relying on retries:
114
101
  |----------|---------|
115
102
  | `rl.mailboxes` | `create`, `list`, `delete`, `update`, `set_recipient_policy` |
116
103
  | `rl.mailboxes.allowlist` | `list`, `add`, `add_bulk`, `delete`, `list_blocked_attempts` |
117
- | `rl.messages` | `send`, `list`, `get`, `reply`, `wait`, `release`, `block` |
104
+ | `rl.messages` | `send`, `list`, `get`, `reply`, `wait`, `release`, `block`, `set_starred` |
118
105
  | `rl.drafts` | `create`, `get`, `list`, `update`, `send`, `delete` |
119
- | `rl.threads` | `list`, `get` |
106
+ | `rl.threads` | `list`, `get`, `set_starred` |
120
107
  | `rl.attachments` | `get_download_url`, `get_preview`, `upload`, `get_upload`, `delete_upload` |
121
108
  | `rl.webhooks` | `create`, `list`, `get`, `update`, `delete`, `rotate_secret`, `test`, `list_deliveries`, `retry_delivery` |
122
109
  | `rl.recipients` | `create`, `list`, `delete`, `resend` |
@@ -145,7 +132,7 @@ if draft["worst_decision"] == "allow":
145
132
 
146
133
  The send/reply/draft-send response carries two additive, nullable keys that explain a held send inline (no second `messages.get` call). `result["scan"]` is the vendor-neutral scanner verdict (`ScanSummary`); `result["hold_context"]` (`{"trigger_source", "summary_reasons"}` or `None`) is the policy/HITL reason, non-null only when the delivery `status` diverges from `scan["verdict"]` because of a policy/HITL hold — a clean scan held for review by your mailbox policy, or a scanner review-flag held as quarantine on a plan without the review queue (`trigger_source`: `mailbox_policy` | `scanner` | `both`).
147
134
 
148
- This SDK always sends **synchronously** — `drafts.send()`, `messages.send()`, and `messages.reply()` return only once the scanner verdict is known, with `scan` and `hold_context` inline. The optimistic-ack async path (`Prefer: respond-async` → `202 queued_for_dispatch`, then poll the message to a terminal state) is a REST-level capability of `POST /v1/drafts/:id/send` only; the SDK exposes no `Prefer` option. To use it, drive that route directly (see ENDPOINTS.md "Asynchronous send (optimistic-ack) & polling") and poll `messages.get(message_id)` (or handle the lifecycle webhook) until `state` is terminal. (`messages.wait()` is a mailbox long-poll for new *inbound* mail, not a way to observe a specific message by ID.)
135
+ By default `drafts.send()`, `messages.send()`, and `messages.reply()` return only once the scanner verdict is known, with `scan` and `hold_context` inline. Pass `async_dispatch=True` to `drafts.send()` to send the `Prefer: respond-async` hint. **The hint is advisory** — the server returns a `202 AsyncSendAck` only when `OUTBOUND_ASYNC_DISPATCH_ENABLED` is on; otherwise it ignores the hint and returns a normal `SendMessageResponse`. **Always branch on the result**: `result["status"] == "queued_for_dispatch"` ⇒ `AsyncSendAck`, otherwise `SendMessageResponse`. Poll `messages.get(message_id)` (or handle the lifecycle webhook) until `state` is terminal. Attachment-bearing drafts fail closed on the async path (`400 ATTACHMENTS_REQUIRE_SYNC_SEND`). (`messages.wait()` is a mailbox long-poll for new *inbound* mail, not a way to observe a specific message by ID.)
149
136
 
150
137
  The send endpoint raises `ReplyLayerError` with distinct `.code` values on 409:
151
138
  - `DRAFT_REJECTED_BY_RESCAN` — send-time scan flipped the verdict to `block`/`quarantine`. The draft stays in `draft` state; edit the body and retry. `err.details` carries `scan` and, when a policy/HITL decision drove the hold, `hold_context`.
@@ -163,7 +150,7 @@ except ReplyLayerError as err:
163
150
 
164
151
  ## Outbound attachments (Pro+)
165
152
 
166
- 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"`.
167
154
 
168
155
  ```python
169
156
  import time
@@ -466,7 +453,7 @@ Error classes: `ReplyLayerError` (base), `AuthenticationError` (401), `Forbidden
466
453
 
467
454
  ## Webhook signature verification
468
455
 
469
- > 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).
470
457
 
471
458
  ```python
472
459
  from replylayer import verify_webhook_signature
@@ -479,6 +466,19 @@ verify_webhook_signature(
479
466
  )
480
467
  ```
481
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
+
482
482
  ## Context managers
483
483
 
484
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.14.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"
@@ -18,6 +18,11 @@ dependencies = [
18
18
  ]
19
19
 
20
20
  [project.optional-dependencies]
21
+ # Optional CLI convenience: `pip install "replylayer[cli]"` also installs the
22
+ # `rly` launcher (the `rly` / `replylayer` command-line tools). The SDK itself
23
+ # is a pure library; this extra is opt-in and is never pulled by a plain
24
+ # `pip install replylayer`.
25
+ cli = ["rly>=0.6.3"]
21
26
  dev = [
22
27
  "pytest>=8.0",
23
28
  "pytest-asyncio>=0.24",
@@ -14,6 +14,11 @@ from .errors import (
14
14
  TimezoneRequiredError,
15
15
  )
16
16
  from .types import (
17
+ # WS1 — star response types (0.16.0).
18
+ MessageStarResponse,
19
+ ThreadStarResponse,
20
+ # WS6-SDK — async optimistic-ack (0.16.0).
21
+ AsyncSendAck,
17
22
  WebhookSummary,
18
23
  WebhookDeliverySummary,
19
24
  WebhookDeliveryStatus,
@@ -71,9 +76,14 @@ from .types import (
71
76
  ScannerPolicy,
72
77
  )
73
78
 
74
- __version__ = "0.14.0"
79
+ __version__ = "0.17.0"
75
80
 
76
81
  __all__ = [
82
+ # WS1 — star response types (0.16.0).
83
+ "MessageStarResponse",
84
+ "ThreadStarResponse",
85
+ # WS6-SDK — async optimistic-ack (0.16.0).
86
+ "AsyncSendAck",
77
87
  "ReplyLayer",
78
88
  "AsyncReplyLayer",
79
89
  "RetryInfo",
@@ -0,0 +1,25 @@
1
+ """Entry point for ``python -m replylayer``.
2
+
3
+ The ``replylayer`` PyPI package is the ReplyLayer Python **SDK** — a library you
4
+ ``import``, not a command-line tool. This module exists only to redirect anyone
5
+ who tries to "run" the package toward the actual CLI (the separate ``rly``
6
+ package), rather than failing silently.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ def main() -> None:
13
+ print(
14
+ "replylayer is the ReplyLayer Python SDK (a library, not a CLI).\n"
15
+ "\n"
16
+ " Use it in code: import replylayer\n"
17
+ " Install the CLI: pipx install rly "
18
+ "# provides the `rly` and `replylayer` commands\n"
19
+ "\n"
20
+ "Docs: https://replylayer.ai"
21
+ )
22
+
23
+
24
+ if __name__ == "__main__":
25
+ main()
@@ -9,7 +9,7 @@ import httpx
9
9
 
10
10
  from .errors import ReplyLayerError, error_from_response
11
11
 
12
- _VERSION = "0.14.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``.
@@ -27,7 +27,7 @@ from typing import Any, AsyncIterator, Iterator, Union
27
27
  from .._http import AsyncHttpClient, SyncHttpClient
28
28
  from .._pagination import async_auto_paginate, sync_auto_paginate
29
29
  from ..errors import TimezoneRequiredError
30
- from ..types import Page
30
+ from ..types import AsyncSendAck, Page, SendMessageResponse
31
31
 
32
32
  DEFAULT_LIMIT = 50
33
33
 
@@ -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)
@@ -187,13 +193,24 @@ class SyncDrafts:
187
193
  payload["attachment_ids"] = attachment_ids
188
194
  return self._http.request("PATCH", f"/v1/drafts/{id}", body=payload)
189
195
 
190
- def send(self, id: str) -> dict[str, Any]:
196
+ def send(self, id: str, *, async_dispatch: bool = False) -> "SendMessageResponse | AsyncSendAck":
191
197
  """Dispatch a draft.
192
198
 
193
199
  Re-runs the scanner authoritatively + the full send-time gate
194
200
  stack (suppressions, reply-loop, budget, etc) before handing the
195
201
  message to the outbound provider.
196
202
 
203
+ Pass ``async_dispatch=True`` to send the ``Prefer: respond-async``
204
+ hint. **The hint is advisory** — the server returns a 202
205
+ ``AsyncSendAck`` (``status == "queued_for_dispatch"``) only when
206
+ ``OUTBOUND_ASYNC_DISPATCH_ENABLED`` is on; otherwise it ignores the
207
+ hint and returns a normal ``SendMessageResponse``. **Always branch on
208
+ the result**: ``status == "queued_for_dispatch"`` ⇒ ``AsyncSendAck``,
209
+ otherwise ``SendMessageResponse``. Attachment-bearing drafts fail
210
+ closed on the async path (``400 ATTACHMENTS_REQUIRE_SYNC_SEND``).
211
+ Poll ``messages.get(message_id)`` until ``state`` is terminal to
212
+ observe the final outcome.
213
+
197
214
  Sandbox accounts are subject to a 250-cumulative-send trial
198
215
  budget. Once exhausted the API returns 403 with
199
216
  ``code='SANDBOX_TRIAL_BUDGET_EXHAUSTED'`` and a ``details``
@@ -201,7 +218,8 @@ class SyncDrafts:
201
218
  cap fires here as on ``messages.send()`` — the cumulative
202
219
  counter is shared across both surfaces.
203
220
  """
204
- return self._http.request("POST", f"/v1/drafts/{id}/send", body={})
221
+ extra_headers: dict[str, str] | None = {"Prefer": "respond-async"} if async_dispatch else None
222
+ return self._http.request("POST", f"/v1/drafts/{id}/send", body={}, extra_headers=extra_headers)
205
223
 
206
224
  def delete(self, id: str) -> None:
207
225
  self._http.request("DELETE", f"/v1/drafts/{id}")
@@ -287,8 +305,14 @@ class AsyncDrafts:
287
305
  q = {**query, "before": cursor or query["before"]}
288
306
  res = await self._http.request("GET", f"/v1/mailboxes/{mailbox_id}/drafts", query=q)
289
307
  drafts = res.get("drafts", [])
290
- next_cursor = drafts[-1]["id"] if len(drafts) == limit else None
291
- 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)
292
316
 
293
317
  if auto_paginate:
294
318
  return async_auto_paginate(fetch_page)
@@ -328,15 +352,17 @@ class AsyncDrafts:
328
352
  payload["attachment_ids"] = attachment_ids
329
353
  return await self._http.request("PATCH", f"/v1/drafts/{id}", body=payload)
330
354
 
331
- async def send(self, id: str) -> dict[str, Any]:
332
- """Dispatch a draft.
355
+ async def send(self, id: str, *, async_dispatch: bool = False) -> "SendMessageResponse | AsyncSendAck":
356
+ """Dispatch a draft (async). See SyncDrafts.send for the full contract.
333
357
 
334
- Sandbox accounts are subject to a 250-cumulative-send trial
335
- budget. Once exhausted the API returns 403 with
336
- ``code='SANDBOX_TRIAL_BUDGET_EXHAUSTED'`` and a ``details``
337
- payload carrying ``feature='sandbox_cumulative_send_cap'``.
358
+ Pass ``async_dispatch=True`` to send the ``Prefer: respond-async``
359
+ hint. The hint is advisory — the server returns a 202 ``AsyncSendAck``
360
+ only when ``OUTBOUND_ASYNC_DISPATCH_ENABLED`` is on; otherwise it
361
+ ignores the hint and returns a normal ``SendMessageResponse``. Always
362
+ branch on ``status == "queued_for_dispatch"`` to distinguish the two.
338
363
  """
339
- return await self._http.request("POST", f"/v1/drafts/{id}/send", body={})
364
+ extra_headers: dict[str, str] | None = {"Prefer": "respond-async"} if async_dispatch else None
365
+ return await self._http.request("POST", f"/v1/drafts/{id}/send", body={}, extra_headers=extra_headers)
340
366
 
341
367
  async def delete(self, id: str) -> None:
342
368
  await self._http.request("DELETE", f"/v1/drafts/{id}")
@@ -4,7 +4,7 @@ from typing import Any, AsyncIterator, Iterator
4
4
 
5
5
  from .._http import AsyncHttpClient, SyncHttpClient
6
6
  from .._pagination import async_auto_paginate, sync_auto_paginate
7
- from ..types import Page
7
+ from ..types import MessageStarResponse, Page
8
8
 
9
9
  DEFAULT_LIMIT = 50
10
10
 
@@ -81,6 +81,8 @@ class SyncMessages:
81
81
  until: str | None = None,
82
82
  search: str | None = None,
83
83
  view: str | None = None,
84
+ starred: bool | None = None,
85
+ has_attachment: bool | None = None,
84
86
  auto_paginate: bool = False,
85
87
  ) -> Union[Page, Iterator[dict[str, Any]]]:
86
88
  """List messages in a mailbox.
@@ -90,6 +92,10 @@ class SyncMessages:
90
92
  server's blind-trigram index has no shorter form. A 1-2 character
91
93
  ``search`` is rejected with HTTP 400 ``code='SEARCH_TERM_TOO_SHORT'``
92
94
  (``details.min_search_length=3``).
95
+
96
+ ``has_attachment`` requires a server advertising
97
+ ``messages.has_attachment_filter`` in ``GET /v1/health``'s
98
+ ``capabilities``; older servers reject the param.
93
99
  """
94
100
  query: dict[str, str | None] = {
95
101
  "limit": str(limit),
@@ -102,6 +108,8 @@ class SyncMessages:
102
108
  "until": until,
103
109
  "search": search,
104
110
  "view": view,
111
+ "starred": str(starred).lower() if starred is not None else None,
112
+ "has_attachment": str(has_attachment).lower() if has_attachment is not None else None,
105
113
  }
106
114
 
107
115
  def fetch_page(cursor: str | None) -> Page:
@@ -195,6 +203,17 @@ class SyncMessages:
195
203
  "POST", f"/v1/messages/{message_id}/read", body={}
196
204
  )
197
205
 
206
+ def set_starred(self, message_id: str, *, starred: bool) -> "MessageStarResponse":
207
+ """Star or unstar a message.
208
+
209
+ ``starred=True`` marks the message as starred (favorited);
210
+ ``starred=False`` clears the star. Idempotent.
211
+ Wraps ``PATCH /v1/messages/:id/star``.
212
+ """
213
+ return self._http.request(
214
+ "PATCH", f"/v1/messages/{message_id}/star", body={"starred": starred}
215
+ )
216
+
198
217
  def approve_review(
199
218
  self, message_id: str, *, reason: str | None = None
200
219
  ) -> dict[str, Any]:
@@ -298,6 +317,8 @@ class AsyncMessages:
298
317
  until: str | None = None,
299
318
  search: str | None = None,
300
319
  view: str | None = None,
320
+ starred: bool | None = None,
321
+ has_attachment: bool | None = None,
301
322
  auto_paginate: bool = False,
302
323
  ) -> Page | AsyncIterator[dict[str, Any]]:
303
324
  """List messages in a mailbox.
@@ -307,6 +328,10 @@ class AsyncMessages:
307
328
  server's blind-trigram index has no shorter form. A 1-2 character
308
329
  ``search`` is rejected with HTTP 400 ``code='SEARCH_TERM_TOO_SHORT'``
309
330
  (``details.min_search_length=3``).
331
+
332
+ ``has_attachment`` requires a server advertising
333
+ ``messages.has_attachment_filter`` in ``GET /v1/health``'s
334
+ ``capabilities``; older servers reject the param.
310
335
  """
311
336
  query: dict[str, str | None] = {
312
337
  "limit": str(limit),
@@ -319,6 +344,8 @@ class AsyncMessages:
319
344
  "until": until,
320
345
  "search": search,
321
346
  "view": view,
347
+ "starred": str(starred).lower() if starred is not None else None,
348
+ "has_attachment": str(has_attachment).lower() if has_attachment is not None else None,
322
349
  }
323
350
 
324
351
  async def fetch_page(cursor: str | None) -> Page:
@@ -396,6 +423,12 @@ class AsyncMessages:
396
423
  "POST", f"/v1/messages/{message_id}/read", body={}
397
424
  )
398
425
 
426
+ async def set_starred(self, message_id: str, *, starred: bool) -> "MessageStarResponse":
427
+ """Star or unstar a message (async). See SyncMessages.set_starred."""
428
+ return await self._http.request(
429
+ "PATCH", f"/v1/messages/{message_id}/star", body={"starred": starred}
430
+ )
431
+
399
432
  async def approve_review(
400
433
  self, message_id: str, *, reason: str | None = None
401
434
  ) -> dict[str, Any]:
@@ -5,7 +5,7 @@ from urllib.parse import quote
5
5
 
6
6
  from .._http import AsyncHttpClient, SyncHttpClient
7
7
  from .._pagination import async_auto_paginate, sync_auto_paginate
8
- from ..types import Page
8
+ from ..types import Page, ThreadStarResponse
9
9
 
10
10
  DEFAULT_LIMIT = 50
11
11
 
@@ -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", [])
@@ -44,8 +48,26 @@ class SyncThreads:
44
48
  return sync_auto_paginate(fetch_page)
45
49
  return fetch_page(None)
46
50
 
47
- def get(self, id: str, *, view: str | None = None, body_format: str | None = None) -> dict[str, Any]:
48
- query = {k: v for k, v in {"view": view, "body_format": body_format}.items() if v is not None}
51
+ def get(
52
+ self,
53
+ id: str,
54
+ *,
55
+ view: str | None = None,
56
+ body_format: str | None = None,
57
+ mailbox: str | None = None,
58
+ ) -> dict[str, Any]:
59
+ """Read a full thread (ordered messages).
60
+
61
+ Account-wide by default; pass ``mailbox`` (name or UUID) to scope the
62
+ lookup to one mailbox when the same thread key collides across two of
63
+ the account's mailboxes. ``mailbox`` requires a server that accepts
64
+ the param — older servers reject it.
65
+ """
66
+ query = {
67
+ k: v for k, v in {
68
+ "view": view, "body_format": body_format, "mailbox": mailbox,
69
+ }.items() if v is not None
70
+ }
49
71
  return self._http.request("GET", f"/v1/threads/{quote(id, safe='')}", query=query)
50
72
 
51
73
  def mark_read(self, mailbox_id: str, thread_id: str) -> dict[str, Any]:
@@ -68,6 +90,20 @@ class SyncThreads:
68
90
  body={},
69
91
  )
70
92
 
93
+ def set_starred(self, mailbox_id: str, thread_id: str, *, starred: bool) -> "ThreadStarResponse":
94
+ """Star or unstar a thread.
95
+
96
+ ``starred=True`` marks the thread as starred; ``starred=False`` clears it.
97
+ Both ``mailbox_id`` and ``thread_id`` are URL-encoded (mirrors existing
98
+ ``mark_read`` behaviour). Wraps
99
+ ``PATCH /v1/mailboxes/:id/threads/:thread_id/star``.
100
+ """
101
+ return self._http.request(
102
+ "PATCH",
103
+ f"/v1/mailboxes/{quote(mailbox_id, safe='')}/threads/{quote(thread_id, safe='')}/star",
104
+ body={"starred": starred},
105
+ )
106
+
71
107
 
72
108
  class AsyncThreads:
73
109
  def __init__(self, http: AsyncHttpClient) -> None:
@@ -83,6 +119,7 @@ class AsyncThreads:
83
119
  has_inbound: bool | None = None,
84
120
  include_firewall_blocked: bool = False,
85
121
  view: str | None = None,
122
+ starred: bool | None = None,
86
123
  auto_paginate: bool = False,
87
124
  ) -> Page | AsyncIterator[dict[str, Any]]:
88
125
  async def fetch_page(cursor: str | None) -> Page:
@@ -93,6 +130,9 @@ class AsyncThreads:
93
130
  "has_inbound": ("true" if has_inbound else "false") if has_inbound is not None else None,
94
131
  "include_firewall_blocked": "true" if include_firewall_blocked else None,
95
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,
96
136
  }
97
137
  res = await self._http.request("GET", f"/v1/mailboxes/{mailbox_id}/threads", query=query)
98
138
  threads = res.get("threads", [])
@@ -104,8 +144,20 @@ class AsyncThreads:
104
144
 
105
145
  return await fetch_page(None)
106
146
 
107
- async def get(self, id: str, *, view: str | None = None, body_format: str | None = None) -> dict[str, Any]:
108
- query = {k: v for k, v in {"view": view, "body_format": body_format}.items() if v is not None}
147
+ async def get(
148
+ self,
149
+ id: str,
150
+ *,
151
+ view: str | None = None,
152
+ body_format: str | None = None,
153
+ mailbox: str | None = None,
154
+ ) -> dict[str, Any]:
155
+ """Read a full thread (async). See SyncThreads.get for the full contract."""
156
+ query = {
157
+ k: v for k, v in {
158
+ "view": view, "body_format": body_format, "mailbox": mailbox,
159
+ }.items() if v is not None
160
+ }
109
161
  return await self._http.request("GET", f"/v1/threads/{quote(id, safe='')}", query=query)
110
162
 
111
163
  async def mark_read(self, mailbox_id: str, thread_id: str) -> dict[str, Any]:
@@ -115,3 +167,11 @@ class AsyncThreads:
115
167
  f"/v1/mailboxes/{quote(mailbox_id, safe='')}/threads/{quote(thread_id, safe='')}/read",
116
168
  body={},
117
169
  )
170
+
171
+ async def set_starred(self, mailbox_id: str, thread_id: str, *, starred: bool) -> "ThreadStarResponse":
172
+ """Star or unstar a thread (async). See SyncThreads.set_starred."""
173
+ return await self._http.request(
174
+ "PATCH",
175
+ f"/v1/mailboxes/{quote(mailbox_id, safe='')}/threads/{quote(thread_id, safe='')}/star",
176
+ body={"starred": starred},
177
+ )