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.
- replylayer-0.14.0/README.md → replylayer-0.17.0/PKG-INFO +37 -5
- replylayer-0.14.0/PKG-INFO → replylayer-0.17.0/README.md +20 -20
- {replylayer-0.14.0 → replylayer-0.17.0}/pyproject.toml +6 -1
- {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/__init__.py +11 -1
- replylayer-0.17.0/replylayer/__main__.py +25 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/_http.py +1 -1
- {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/attachments.py +3 -2
- {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/drafts.py +40 -14
- {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/messages.py +34 -1
- {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/threads.py +65 -5
- {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/types.py +32 -2
- {replylayer-0.14.0 → replylayer-0.17.0}/tests/test_drafts.py +63 -3
- replylayer-0.17.0/tests/test_threads.py +96 -0
- replylayer-0.17.0/tests/test_version.py +18 -0
- replylayer-0.17.0/tests/test_ws1_ws6.py +369 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/uv.lock +21 -2
- {replylayer-0.14.0 → replylayer-0.17.0}/.gitignore +0 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/_client.py +0 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/_pagination.py +0 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/errors.py +0 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/py.typed +0 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/__init__.py +0 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/account.py +0 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/api_keys.py +0 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/domains.py +0 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/health.py +0 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/inbound_blocklist.py +0 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/legal_holds.py +0 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/mailboxes.py +0 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/recipients.py +0 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/suppressions.py +0 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/replylayer/resources/webhooks.py +0 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/tests/__init__.py +0 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/tests/test_async.py +0 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/tests/test_attachments.py +0 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/tests/test_client.py +0 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/tests/test_domains.py +0 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/tests/test_hitl_review_types.py +0 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/tests/test_http.py +0 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/tests/test_resources.py +0 -0
- {replylayer-0.14.0 → replylayer-0.17.0}/tests/test_web_risk_types.py +0 -0
- {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
|
-
|
|
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**
|
|
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
|
|
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
|
-
|
|
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**
|
|
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
|
|
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.
|
|
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.
|
|
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()
|
|
@@ -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``.
|
|
@@ -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
|
-
|
|
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)
|
|
@@ -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) ->
|
|
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
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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) ->
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
``
|
|
337
|
-
|
|
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
|
-
|
|
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(
|
|
48
|
-
|
|
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(
|
|
108
|
-
|
|
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
|
+
)
|