replylayer 0.16.0__tar.gz → 0.18.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {replylayer-0.16.0 → replylayer-0.18.0}/.gitignore +4 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/PKG-INFO +35 -5
- {replylayer-0.16.0 → replylayer-0.18.0}/README.md +34 -4
- {replylayer-0.16.0 → replylayer-0.18.0}/pyproject.toml +1 -1
- {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/__init__.py +12 -1
- {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/_http.py +1 -1
- replylayer-0.18.0/replylayer/resources/account.py +97 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/attachments.py +3 -2
- {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/drafts.py +16 -4
- {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/messages.py +172 -5
- {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/threads.py +8 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/types.py +82 -2
- {replylayer-0.16.0 → replylayer-0.18.0}/tests/test_async.py +67 -1
- {replylayer-0.16.0 → replylayer-0.18.0}/tests/test_domains.py +2 -2
- {replylayer-0.16.0 → replylayer-0.18.0}/tests/test_drafts.py +63 -3
- replylayer-0.18.0/tests/test_messages_idempotency.py +285 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/tests/test_resources.py +68 -5
- replylayer-0.18.0/tests/test_threads.py +96 -0
- replylayer-0.18.0/tests/test_version.py +35 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/tests/test_web_risk_types.py +53 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/uv.lock +1 -1
- replylayer-0.16.0/replylayer/resources/account.py +0 -36
- replylayer-0.16.0/tests/test_version.py +0 -18
- {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/__main__.py +0 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/_client.py +0 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/_pagination.py +0 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/errors.py +0 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/py.typed +0 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/__init__.py +0 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/api_keys.py +0 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/domains.py +0 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/health.py +0 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/inbound_blocklist.py +0 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/legal_holds.py +0 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/mailboxes.py +0 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/recipients.py +0 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/suppressions.py +0 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/webhooks.py +0 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/tests/__init__.py +0 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/tests/test_attachments.py +0 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/tests/test_client.py +0 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/tests/test_hitl_review_types.py +0 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/tests/test_http.py +0 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/tests/test_webhooks.py +0 -0
- {replylayer-0.16.0 → replylayer-0.18.0}/tests/test_ws1_ws6.py +0 -0
|
@@ -52,3 +52,7 @@ plans/fault-briefings/
|
|
|
52
52
|
# under audits/ — these are signed-verdict-anchored evidence per
|
|
53
53
|
# plans/granite-guardian-failover-saturation-stress-test.md §11.
|
|
54
54
|
!audits/**/*.log
|
|
55
|
+
uat-grading-results*.json
|
|
56
|
+
|
|
57
|
+
# Scanner eval run outputs (corpora are tracked, results are not)
|
|
58
|
+
packages/scanner/src/eval/*-results.json
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: replylayer
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.18.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
|
|
@@ -126,7 +126,7 @@ contract — read it before relying on retries:
|
|
|
126
126
|
| `rl.recipients` | `create`, `list`, `delete`, `resend` |
|
|
127
127
|
| `rl.suppressions` | `list`, `delete` |
|
|
128
128
|
| `rl.api_keys` | `create`, `list`, `revoke`, `rotate`* |
|
|
129
|
-
| `rl.account` | `get_usage` |
|
|
129
|
+
| `rl.account` | `get_usage`, `get_quota`, `get_link_scanning_status`, `enable_link_scanning` |
|
|
130
130
|
| `rl.health` | `check` |
|
|
131
131
|
|
|
132
132
|
*`api_keys.rotate()` revokes the calling API key and returns a new one. After calling it, this SDK instance's key is invalidated — create a new `ReplyLayer` instance with the returned key.
|
|
@@ -152,7 +152,7 @@ The send/reply/draft-send response carries two additive, nullable keys that expl
|
|
|
152
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.)
|
|
153
153
|
|
|
154
154
|
The send endpoint raises `ReplyLayerError` with distinct `.code` values on 409:
|
|
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`.
|
|
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`, `releasable` (`True` for a `quarantine` hold the customer can release via `POST /v1/drafts/:id/release-and-send`, `False` for a terminal `block`), and, when a policy/HITL decision drove the hold, `hold_context`.
|
|
156
156
|
- `DRAFT_ALREADY_SENT` — the draft was already sent (race or retry after success).
|
|
157
157
|
|
|
158
158
|
```python
|
|
@@ -167,7 +167,7 @@ except ReplyLayerError as err:
|
|
|
167
167
|
|
|
168
168
|
## Outbound attachments (Pro+)
|
|
169
169
|
|
|
170
|
-
Attaching a file is a **two-phase** flow: upload the bytes to stage a handle, then reference `handle["id"]` in a send/reply/draft `attachment_ids` list. Every attachment is scanned (byte-level family validation + AV + secrets/PII over extracted text **and** filename) before it leaves. The mailbox must have outbound attachments **explicitly enabled**
|
|
170
|
+
Attaching a file is a **two-phase** flow: upload the bytes to stage a handle, then reference `handle["id"]` in a send/reply/draft `attachment_ids` list. Every attachment is scanned (byte-level family validation + AV + secrets/PII over extracted text **and** filename) before it leaves. The mailbox must have outbound attachments **explicitly enabled** by a human account owner in the dashboard (Pro+, mailbox Settings page, TOTP/password re-auth). Once enabled, API keys can send attachments; uploads to a non-enabled mailbox raise `ForbiddenError` with `code="OUTBOUND_ATTACHMENTS_DISABLED"`.
|
|
171
171
|
|
|
172
172
|
```python
|
|
173
173
|
import time
|
|
@@ -426,6 +426,23 @@ Webhook deliveries are deduped server-side to at most one per `(account, mailbox
|
|
|
426
426
|
|
|
427
427
|
The MCP tool `list_allowlist_blocked_attempts` exposes the same view to agents — read-only by design. There is no dismiss-attempt tool (the containment boundary would be moot if an agent could clear its own rejection history).
|
|
428
428
|
|
|
429
|
+
## Malicious link scanning (URL reputation)
|
|
430
|
+
|
|
431
|
+
Malicious link scanning checks inbound links against Google Web Risk (only SHA-256 hash-prefixes are sent — full URLs never leave the platform). It is off by default and opt-in per account. Enabling it is an **admin action** (it turns on an account-wide sub-processor data flow), so agent-scoped keys can read status but not enable it (they raise `ForbiddenError`).
|
|
432
|
+
|
|
433
|
+
Pass the version you are acknowledging explicitly; the SDK does not auto-fetch it, so the consent is recorded against a version your code chose:
|
|
434
|
+
|
|
435
|
+
```python
|
|
436
|
+
status = rl.account.get_link_scanning_status()
|
|
437
|
+
# {"active", "accepted_version", "current_version", "privacy_ok"}
|
|
438
|
+
|
|
439
|
+
if not status["active"] and status["privacy_ok"]:
|
|
440
|
+
res = rl.account.enable_link_scanning(accept_web_risk_version=status["current_version"])
|
|
441
|
+
# res["url_reputation"]["active"] is True; res["disclosure"]["notice"] / ["advisory_url"]
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
If `privacy_ok` is `False` the account's accepted privacy policy predates the disclosed sub-processor — re-accept the current Privacy Policy first (`enable_link_scanning` would otherwise raise a `409 PRIVACY_VERSION_TOO_OLD`). The async client exposes the same methods (`await rl.account.get_link_scanning_status()` / `await rl.account.enable_link_scanning(...)`).
|
|
445
|
+
|
|
429
446
|
## Mailbox identifiers
|
|
430
447
|
|
|
431
448
|
Every SDK method that takes a `mailbox_id` argument accepts **either the mailbox's UUID or its name**. The server resolves names against the authenticated account's active mailboxes. `rl.messages.list("support-bot")` and `rl.messages.list("a1b2-…")` are equivalent.
|
|
@@ -470,7 +487,7 @@ Error classes: `ReplyLayerError` (base), `AuthenticationError` (401), `Forbidden
|
|
|
470
487
|
|
|
471
488
|
## Webhook signature verification
|
|
472
489
|
|
|
473
|
-
> For a full integration guide (event catalog, retry behavior, idempotency, security, troubleshooting), see
|
|
490
|
+
> For a full integration guide (event catalog, retry behavior, idempotency, security, troubleshooting), see the hosted webhook docs (coming).
|
|
474
491
|
|
|
475
492
|
```python
|
|
476
493
|
from replylayer import verify_webhook_signature
|
|
@@ -483,6 +500,19 @@ verify_webhook_signature(
|
|
|
483
500
|
)
|
|
484
501
|
```
|
|
485
502
|
|
|
503
|
+
Once verified, parse and dispatch on the event type. **The discriminator field is `event`, not `type`:**
|
|
504
|
+
|
|
505
|
+
```python
|
|
506
|
+
import json
|
|
507
|
+
|
|
508
|
+
payload = json.loads(request.body)
|
|
509
|
+
# payload["event"] is the discriminator — NOT payload["type"]
|
|
510
|
+
if payload["event"] == "message.received":
|
|
511
|
+
# handle inbound message
|
|
512
|
+
elif payload["event"] == "message.dispatch_failed":
|
|
513
|
+
# handle failed outbound send
|
|
514
|
+
```
|
|
515
|
+
|
|
486
516
|
## Context managers
|
|
487
517
|
|
|
488
518
|
Both clients support context managers to properly close connection pools:
|
|
@@ -109,7 +109,7 @@ contract — read it before relying on retries:
|
|
|
109
109
|
| `rl.recipients` | `create`, `list`, `delete`, `resend` |
|
|
110
110
|
| `rl.suppressions` | `list`, `delete` |
|
|
111
111
|
| `rl.api_keys` | `create`, `list`, `revoke`, `rotate`* |
|
|
112
|
-
| `rl.account` | `get_usage` |
|
|
112
|
+
| `rl.account` | `get_usage`, `get_quota`, `get_link_scanning_status`, `enable_link_scanning` |
|
|
113
113
|
| `rl.health` | `check` |
|
|
114
114
|
|
|
115
115
|
*`api_keys.rotate()` revokes the calling API key and returns a new one. After calling it, this SDK instance's key is invalidated — create a new `ReplyLayer` instance with the returned key.
|
|
@@ -135,7 +135,7 @@ The send/reply/draft-send response carries two additive, nullable keys that expl
|
|
|
135
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.)
|
|
136
136
|
|
|
137
137
|
The send endpoint raises `ReplyLayerError` with distinct `.code` values on 409:
|
|
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`.
|
|
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`, `releasable` (`True` for a `quarantine` hold the customer can release via `POST /v1/drafts/:id/release-and-send`, `False` for a terminal `block`), and, when a policy/HITL decision drove the hold, `hold_context`.
|
|
139
139
|
- `DRAFT_ALREADY_SENT` — the draft was already sent (race or retry after success).
|
|
140
140
|
|
|
141
141
|
```python
|
|
@@ -150,7 +150,7 @@ except ReplyLayerError as err:
|
|
|
150
150
|
|
|
151
151
|
## Outbound attachments (Pro+)
|
|
152
152
|
|
|
153
|
-
Attaching a file is a **two-phase** flow: upload the bytes to stage a handle, then reference `handle["id"]` in a send/reply/draft `attachment_ids` list. Every attachment is scanned (byte-level family validation + AV + secrets/PII over extracted text **and** filename) before it leaves. The mailbox must have outbound attachments **explicitly enabled**
|
|
153
|
+
Attaching a file is a **two-phase** flow: upload the bytes to stage a handle, then reference `handle["id"]` in a send/reply/draft `attachment_ids` list. Every attachment is scanned (byte-level family validation + AV + secrets/PII over extracted text **and** filename) before it leaves. The mailbox must have outbound attachments **explicitly enabled** by a human account owner in the dashboard (Pro+, mailbox Settings page, TOTP/password re-auth). Once enabled, API keys can send attachments; uploads to a non-enabled mailbox raise `ForbiddenError` with `code="OUTBOUND_ATTACHMENTS_DISABLED"`.
|
|
154
154
|
|
|
155
155
|
```python
|
|
156
156
|
import time
|
|
@@ -409,6 +409,23 @@ Webhook deliveries are deduped server-side to at most one per `(account, mailbox
|
|
|
409
409
|
|
|
410
410
|
The MCP tool `list_allowlist_blocked_attempts` exposes the same view to agents — read-only by design. There is no dismiss-attempt tool (the containment boundary would be moot if an agent could clear its own rejection history).
|
|
411
411
|
|
|
412
|
+
## Malicious link scanning (URL reputation)
|
|
413
|
+
|
|
414
|
+
Malicious link scanning checks inbound links against Google Web Risk (only SHA-256 hash-prefixes are sent — full URLs never leave the platform). It is off by default and opt-in per account. Enabling it is an **admin action** (it turns on an account-wide sub-processor data flow), so agent-scoped keys can read status but not enable it (they raise `ForbiddenError`).
|
|
415
|
+
|
|
416
|
+
Pass the version you are acknowledging explicitly; the SDK does not auto-fetch it, so the consent is recorded against a version your code chose:
|
|
417
|
+
|
|
418
|
+
```python
|
|
419
|
+
status = rl.account.get_link_scanning_status()
|
|
420
|
+
# {"active", "accepted_version", "current_version", "privacy_ok"}
|
|
421
|
+
|
|
422
|
+
if not status["active"] and status["privacy_ok"]:
|
|
423
|
+
res = rl.account.enable_link_scanning(accept_web_risk_version=status["current_version"])
|
|
424
|
+
# res["url_reputation"]["active"] is True; res["disclosure"]["notice"] / ["advisory_url"]
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
If `privacy_ok` is `False` the account's accepted privacy policy predates the disclosed sub-processor — re-accept the current Privacy Policy first (`enable_link_scanning` would otherwise raise a `409 PRIVACY_VERSION_TOO_OLD`). The async client exposes the same methods (`await rl.account.get_link_scanning_status()` / `await rl.account.enable_link_scanning(...)`).
|
|
428
|
+
|
|
412
429
|
## Mailbox identifiers
|
|
413
430
|
|
|
414
431
|
Every SDK method that takes a `mailbox_id` argument accepts **either the mailbox's UUID or its name**. The server resolves names against the authenticated account's active mailboxes. `rl.messages.list("support-bot")` and `rl.messages.list("a1b2-…")` are equivalent.
|
|
@@ -453,7 +470,7 @@ Error classes: `ReplyLayerError` (base), `AuthenticationError` (401), `Forbidden
|
|
|
453
470
|
|
|
454
471
|
## Webhook signature verification
|
|
455
472
|
|
|
456
|
-
> 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).
|
|
457
474
|
|
|
458
475
|
```python
|
|
459
476
|
from replylayer import verify_webhook_signature
|
|
@@ -466,6 +483,19 @@ verify_webhook_signature(
|
|
|
466
483
|
)
|
|
467
484
|
```
|
|
468
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
|
+
|
|
469
499
|
## Context managers
|
|
470
500
|
|
|
471
501
|
Both clients support context managers to properly close connection pools:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from ._client import ReplyLayer, AsyncReplyLayer
|
|
2
2
|
from ._http import RetryInfo
|
|
3
|
+
from .resources.messages import IdempotencyReplayResult
|
|
3
4
|
from .resources.webhooks import verify_webhook_signature
|
|
4
5
|
from .errors import (
|
|
5
6
|
ReplyLayerError,
|
|
@@ -57,6 +58,10 @@ from .types import (
|
|
|
57
58
|
WebRiskLearnMore,
|
|
58
59
|
WebRiskWarning,
|
|
59
60
|
WebRiskThreatType,
|
|
61
|
+
LinkScanningStatus,
|
|
62
|
+
EnableLinkScanningResponse,
|
|
63
|
+
EnableLinkScanningDisclosure,
|
|
64
|
+
AgentSafetyContext,
|
|
60
65
|
MessageReceivedSafetySignal,
|
|
61
66
|
MessageReceivedWebhookPayload,
|
|
62
67
|
# PR 6 — HITL review queue webhook payload types.
|
|
@@ -76,7 +81,7 @@ from .types import (
|
|
|
76
81
|
ScannerPolicy,
|
|
77
82
|
)
|
|
78
83
|
|
|
79
|
-
__version__ = "0.
|
|
84
|
+
__version__ = "0.18.0"
|
|
80
85
|
|
|
81
86
|
__all__ = [
|
|
82
87
|
# WS1 — star response types (0.16.0).
|
|
@@ -84,6 +89,8 @@ __all__ = [
|
|
|
84
89
|
"ThreadStarResponse",
|
|
85
90
|
# WS6-SDK — async optimistic-ack (0.16.0).
|
|
86
91
|
"AsyncSendAck",
|
|
92
|
+
# Track 1 — non-throwing idempotency replay probe result.
|
|
93
|
+
"IdempotencyReplayResult",
|
|
87
94
|
"ReplyLayer",
|
|
88
95
|
"AsyncReplyLayer",
|
|
89
96
|
"RetryInfo",
|
|
@@ -132,6 +139,10 @@ __all__ = [
|
|
|
132
139
|
"WebRiskLearnMore",
|
|
133
140
|
"WebRiskWarning",
|
|
134
141
|
"WebRiskThreatType",
|
|
142
|
+
"LinkScanningStatus",
|
|
143
|
+
"EnableLinkScanningResponse",
|
|
144
|
+
"EnableLinkScanningDisclosure",
|
|
145
|
+
"AgentSafetyContext",
|
|
135
146
|
"MessageReceivedSafetySignal",
|
|
136
147
|
"MessageReceivedWebhookPayload",
|
|
137
148
|
# PR 6 — HITL review queue.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .._http import AsyncHttpClient, SyncHttpClient
|
|
6
|
+
from ..errors import ReplyLayerError
|
|
7
|
+
from ..types import (
|
|
8
|
+
EnableLinkScanningResponse,
|
|
9
|
+
LinkScanningStatus,
|
|
10
|
+
QuotaResponse,
|
|
11
|
+
UsageResponse,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# Raised when the deployed API predates the malicious-link-scanning surface and
|
|
15
|
+
# omits the url_reputation block from GET /v1/auth/me. Mirrors the TS SDK / CLI
|
|
16
|
+
# clear "API outdated" error instead of leaking a raw KeyError.
|
|
17
|
+
_LINK_SCANNING_UNSUPPORTED_MSG = (
|
|
18
|
+
"Server did not return link-scanning status; the API may be outdated."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SyncAccount:
|
|
23
|
+
def __init__(self, http: SyncHttpClient) -> None:
|
|
24
|
+
self._http = http
|
|
25
|
+
|
|
26
|
+
def get_usage(self) -> UsageResponse:
|
|
27
|
+
return self._http.request("GET", "/v1/accounts/usage")
|
|
28
|
+
|
|
29
|
+
def get_quota(self) -> QuotaResponse:
|
|
30
|
+
return self._http.request("GET", "/v1/accounts/quota")
|
|
31
|
+
|
|
32
|
+
def get_link_scanning_status(self) -> LinkScanningStatus:
|
|
33
|
+
"""Malicious link scanning (URL reputation, Google Web Risk) status.
|
|
34
|
+
|
|
35
|
+
Reads the ``url_reputation`` block off GET /v1/auth/me. Works with any key.
|
|
36
|
+
"""
|
|
37
|
+
me = self._http.request("GET", "/v1/auth/me")
|
|
38
|
+
if "url_reputation" not in me:
|
|
39
|
+
raise ReplyLayerError(0, "LINK_SCANNING_UNSUPPORTED", _LINK_SCANNING_UNSUPPORTED_MSG)
|
|
40
|
+
return me["url_reputation"]
|
|
41
|
+
|
|
42
|
+
def enable_link_scanning(self, accept_web_risk_version: str) -> EnableLinkScanningResponse:
|
|
43
|
+
"""Enable malicious link scanning.
|
|
44
|
+
|
|
45
|
+
Pass the disclaimer version you are acknowledging (from
|
|
46
|
+
``get_link_scanning_status()["current_version"]``) — the consent is explicit.
|
|
47
|
+
Requires an admin key or session (agent keys get 403). The response includes
|
|
48
|
+
the disclosure you accepted.
|
|
49
|
+
"""
|
|
50
|
+
return self._http.request(
|
|
51
|
+
"POST",
|
|
52
|
+
"/v1/accounts/url-reputation",
|
|
53
|
+
body={"accept_web_risk_version": accept_web_risk_version},
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def export(self) -> dict[str, Any]:
|
|
57
|
+
"""GDPR Art. 20 data portability export — the full account-data object."""
|
|
58
|
+
return self._http.request("GET", "/v1/accounts/export")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AsyncAccount:
|
|
62
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
63
|
+
self._http = http
|
|
64
|
+
|
|
65
|
+
async def get_usage(self) -> UsageResponse:
|
|
66
|
+
return await self._http.request("GET", "/v1/accounts/usage")
|
|
67
|
+
|
|
68
|
+
async def get_quota(self) -> QuotaResponse:
|
|
69
|
+
return await self._http.request("GET", "/v1/accounts/quota")
|
|
70
|
+
|
|
71
|
+
async def get_link_scanning_status(self) -> LinkScanningStatus:
|
|
72
|
+
"""Malicious link scanning (URL reputation, Google Web Risk) status.
|
|
73
|
+
|
|
74
|
+
Reads the ``url_reputation`` block off GET /v1/auth/me. Works with any key.
|
|
75
|
+
"""
|
|
76
|
+
me = await self._http.request("GET", "/v1/auth/me")
|
|
77
|
+
if "url_reputation" not in me:
|
|
78
|
+
raise ReplyLayerError(0, "LINK_SCANNING_UNSUPPORTED", _LINK_SCANNING_UNSUPPORTED_MSG)
|
|
79
|
+
return me["url_reputation"]
|
|
80
|
+
|
|
81
|
+
async def enable_link_scanning(self, accept_web_risk_version: str) -> EnableLinkScanningResponse:
|
|
82
|
+
"""Enable malicious link scanning.
|
|
83
|
+
|
|
84
|
+
Pass the disclaimer version you are acknowledging (from
|
|
85
|
+
``get_link_scanning_status()["current_version"]``) — the consent is explicit.
|
|
86
|
+
Requires an admin key or session (agent keys get 403). The response includes
|
|
87
|
+
the disclosure you accepted.
|
|
88
|
+
"""
|
|
89
|
+
return await self._http.request(
|
|
90
|
+
"POST",
|
|
91
|
+
"/v1/accounts/url-reputation",
|
|
92
|
+
body={"accept_web_risk_version": accept_web_risk_version},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
async def export(self) -> dict[str, Any]:
|
|
96
|
+
"""GDPR Art. 20 data portability export — the full account-data object."""
|
|
97
|
+
return await self._http.request("GET", "/v1/accounts/export")
|
|
@@ -51,8 +51,9 @@ class SyncAttachments:
|
|
|
51
51
|
content_type: str | None = None,
|
|
52
52
|
) -> UploadAttachmentResponse:
|
|
53
53
|
"""Stage an outbound attachment (phase 1). Returns an opaque handle;
|
|
54
|
-
pass ``handle["id"]`` in a send/reply/draft ``attachment_ids`` list.
|
|
55
|
-
|
|
54
|
+
pass ``handle["id"]`` in a send/reply/draft ``attachment_ids`` list. A
|
|
55
|
+
human account owner must first enable outbound attachments for the
|
|
56
|
+
mailbox in the dashboard (Pro+, TOTP/password re-auth). The returned
|
|
56
57
|
``content_scan_status`` is ``"pending"`` — poll :meth:`get_upload` until
|
|
57
58
|
terminal before referencing the handle, or the send fails with
|
|
58
59
|
``ATTACHMENT_SCAN_PENDING``.
|
|
@@ -146,8 +146,14 @@ class SyncDrafts:
|
|
|
146
146
|
q = {**query, "before": cursor or query["before"]}
|
|
147
147
|
res = self._http.request("GET", f"/v1/mailboxes/{mailbox_id}/drafts", query=q)
|
|
148
148
|
drafts = res.get("drafts", [])
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
# UAT-22 — consume the server's authoritative `has_more` (#296) instead
|
|
150
|
+
# of the old `len(drafts) == limit` inference, which over-reported at
|
|
151
|
+
# the exact-`limit` boundary. Derive `next_cursor` FROM has_more so we
|
|
152
|
+
# only advance when the server says another page exists. Defensive
|
|
153
|
+
# default `False` for a pre-#296 server that omits the field.
|
|
154
|
+
has_more = res.get("has_more") is True
|
|
155
|
+
next_cursor = drafts[-1]["id"] if has_more and drafts else None
|
|
156
|
+
return Page(data=drafts, has_more=has_more, cursor=next_cursor)
|
|
151
157
|
|
|
152
158
|
if auto_paginate:
|
|
153
159
|
return sync_auto_paginate(fetch_page)
|
|
@@ -299,8 +305,14 @@ class AsyncDrafts:
|
|
|
299
305
|
q = {**query, "before": cursor or query["before"]}
|
|
300
306
|
res = await self._http.request("GET", f"/v1/mailboxes/{mailbox_id}/drafts", query=q)
|
|
301
307
|
drafts = res.get("drafts", [])
|
|
302
|
-
|
|
303
|
-
|
|
308
|
+
# UAT-22 — consume the server's authoritative `has_more` (#296) instead
|
|
309
|
+
# of the old `len(drafts) == limit` inference (byte-identical to the
|
|
310
|
+
# sync method). A sync-only fix would leave this async path still
|
|
311
|
+
# over-reporting at the exact-`limit` boundary. Defensive default
|
|
312
|
+
# `False` for a pre-#296 server that omits the field.
|
|
313
|
+
has_more = res.get("has_more") is True
|
|
314
|
+
next_cursor = drafts[-1]["id"] if has_more and drafts else None
|
|
315
|
+
return Page(data=drafts, has_more=has_more, cursor=next_cursor)
|
|
304
316
|
|
|
305
317
|
if auto_paginate:
|
|
306
318
|
return async_auto_paginate(fetch_page)
|
|
@@ -1,13 +1,72 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, AsyncIterator, Iterator, Literal
|
|
4
5
|
|
|
5
6
|
from .._http import AsyncHttpClient, SyncHttpClient
|
|
6
7
|
from .._pagination import async_auto_paginate, sync_auto_paginate
|
|
8
|
+
from ..errors import ReplyLayerError
|
|
7
9
|
from ..types import MessageStarResponse, Page
|
|
8
10
|
|
|
9
11
|
DEFAULT_LIMIT = 50
|
|
10
12
|
|
|
13
|
+
# Track 1 §3a — wire codes the replay-probe (GET /v1/messages/idempotency)
|
|
14
|
+
# returns on a 409. Matched on ``ReplyLayerError.code`` verbatim (NOT a
|
|
15
|
+
# substring-of-message match), since the base client exposes the wire code.
|
|
16
|
+
_IDEMPOTENT_REQUEST_IN_FLIGHT = "IDEMPOTENT_REQUEST_IN_FLIGHT"
|
|
17
|
+
_IDEMPOTENT_REQUEST_NOT_PROVEN_SENT = "IDEMPOTENT_REQUEST_NOT_PROVEN_SENT"
|
|
18
|
+
_IDEMPOTENCY_KEY_BOUND_TO_DRAFT = "IDEMPOTENCY_KEY_BOUND_TO_DRAFT"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class IdempotencyReplayResult:
|
|
23
|
+
"""Discriminated result of the non-throwing replay probe (Track 1 §3a).
|
|
24
|
+
|
|
25
|
+
``kind`` is the tag:
|
|
26
|
+
* ``'miss'`` — 404, no prior keyed row. Proceed: upload + keyed POST.
|
|
27
|
+
* ``'replay'`` — 200, a prior result exists. ``message`` holds it.
|
|
28
|
+
* ``'in_flight'`` — 409 IDEMPOTENT_REQUEST_IN_FLIGHT. ``retry_after``
|
|
29
|
+
(seconds) is sourced from the response BODY
|
|
30
|
+
``details.retry_after`` (NOT the ``Retry-After``
|
|
31
|
+
header — the base client only parses that for 429).
|
|
32
|
+
* ``'not_proven_sent'`` — 409 IDEMPOTENT_REQUEST_NOT_PROVEN_SENT.
|
|
33
|
+
* ``'bound_to_draft'`` — 409 IDEMPOTENCY_KEY_BOUND_TO_DRAFT (cross-namespace).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
kind: Literal["miss", "replay", "in_flight", "not_proven_sent", "bound_to_draft"]
|
|
37
|
+
message: dict[str, Any] | None = None
|
|
38
|
+
retry_after: int | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _classify_idempotency_probe_error(
|
|
42
|
+
err: ReplyLayerError,
|
|
43
|
+
) -> IdempotencyReplayResult | None:
|
|
44
|
+
"""Map a raised probe error to a discriminated result, or ``None`` to
|
|
45
|
+
signal the caller should RE-RAISE (every other non-2xx: 401/403/500/…).
|
|
46
|
+
|
|
47
|
+
Matched on the wire ``status_code`` + ``code`` (the typed error exposes
|
|
48
|
+
both), never on a string-in-message match.
|
|
49
|
+
"""
|
|
50
|
+
if err.status_code == 404:
|
|
51
|
+
return IdempotencyReplayResult(kind="miss")
|
|
52
|
+
if err.status_code == 409:
|
|
53
|
+
if err.code == _IDEMPOTENT_REQUEST_IN_FLIGHT:
|
|
54
|
+
retry_after: int | None = None
|
|
55
|
+
details = err.details
|
|
56
|
+
if isinstance(details, dict):
|
|
57
|
+
raw = details.get("retry_after")
|
|
58
|
+
if isinstance(raw, bool):
|
|
59
|
+
raw = None
|
|
60
|
+
if isinstance(raw, (int, float)):
|
|
61
|
+
retry_after = int(raw)
|
|
62
|
+
return IdempotencyReplayResult(kind="in_flight", retry_after=retry_after)
|
|
63
|
+
if err.code == _IDEMPOTENT_REQUEST_NOT_PROVEN_SENT:
|
|
64
|
+
return IdempotencyReplayResult(kind="not_proven_sent")
|
|
65
|
+
if err.code == _IDEMPOTENCY_KEY_BOUND_TO_DRAFT:
|
|
66
|
+
return IdempotencyReplayResult(kind="bound_to_draft")
|
|
67
|
+
# Any other non-2xx (401/403/500/other-409-code) → re-raise.
|
|
68
|
+
return None
|
|
69
|
+
|
|
11
70
|
|
|
12
71
|
class SyncMessages:
|
|
13
72
|
def __init__(self, http: SyncHttpClient) -> None:
|
|
@@ -25,6 +84,7 @@ class SyncMessages:
|
|
|
25
84
|
subaddress_instance_id: str | None = None,
|
|
26
85
|
subaddress_mode: str | None = None,
|
|
27
86
|
attachment_ids: list[str] | None = None,
|
|
87
|
+
idempotency_key: str | None = None,
|
|
28
88
|
) -> dict[str, Any]:
|
|
29
89
|
"""Send an outbound message.
|
|
30
90
|
|
|
@@ -37,6 +97,13 @@ class SyncMessages:
|
|
|
37
97
|
to a thread participant is admitted past an ``allowlist`` mailbox
|
|
38
98
|
via the thread-scoped bypass.
|
|
39
99
|
|
|
100
|
+
``idempotency_key`` (Track 1) makes a retried same-key immediate send
|
|
101
|
+
produce at most one email + one charge: the server replays the prior
|
|
102
|
+
message instead of re-sending. The key travels in the ``Idempotency-Key``
|
|
103
|
+
request header; it is permanent (no expiry) and must be a stable string
|
|
104
|
+
per send intent. See :meth:`get_idempotency_replay` for the read-only
|
|
105
|
+
probe the attachment-taking wrappers use before re-uploading.
|
|
106
|
+
|
|
40
107
|
Sandbox accounts are subject to a 250-cumulative-send trial
|
|
41
108
|
budget. Once exhausted the API returns 403 with
|
|
42
109
|
``code='SANDBOX_TRIAL_BUDGET_EXHAUSTED'`` and a ``details``
|
|
@@ -65,7 +132,12 @@ class SyncMessages:
|
|
|
65
132
|
payload["subaddress_mode"] = subaddress_mode
|
|
66
133
|
if attachment_ids is not None:
|
|
67
134
|
payload["attachment_ids"] = attachment_ids
|
|
68
|
-
|
|
135
|
+
extra_headers: dict[str, str] | None = None
|
|
136
|
+
if idempotency_key is not None:
|
|
137
|
+
extra_headers = {"Idempotency-Key": idempotency_key}
|
|
138
|
+
return self._http.request(
|
|
139
|
+
"POST", "/v1/messages/send", body=payload, extra_headers=extra_headers
|
|
140
|
+
)
|
|
69
141
|
|
|
70
142
|
def list(
|
|
71
143
|
self,
|
|
@@ -136,7 +208,15 @@ class SyncMessages:
|
|
|
136
208
|
subaddress_instance_id: str | None = None,
|
|
137
209
|
subaddress_mode: str | None = None,
|
|
138
210
|
attachment_ids: list[str] | None = None,
|
|
211
|
+
idempotency_key: str | None = None,
|
|
139
212
|
) -> dict[str, Any]:
|
|
213
|
+
"""Reply to an inbound message.
|
|
214
|
+
|
|
215
|
+
``idempotency_key`` (Track 1) makes a retried same-key reply produce at
|
|
216
|
+
most one email + one charge — the server replays the prior message
|
|
217
|
+
instead of re-sending. The key travels in the ``Idempotency-Key``
|
|
218
|
+
request header. See :meth:`get_idempotency_replay`.
|
|
219
|
+
"""
|
|
140
220
|
payload: dict[str, Any] = {"body": body}
|
|
141
221
|
if html is not None:
|
|
142
222
|
payload["html"] = html
|
|
@@ -146,7 +226,49 @@ class SyncMessages:
|
|
|
146
226
|
payload["subaddress_mode"] = subaddress_mode
|
|
147
227
|
if attachment_ids is not None:
|
|
148
228
|
payload["attachment_ids"] = attachment_ids
|
|
149
|
-
|
|
229
|
+
extra_headers: dict[str, str] | None = None
|
|
230
|
+
if idempotency_key is not None:
|
|
231
|
+
extra_headers = {"Idempotency-Key": idempotency_key}
|
|
232
|
+
return self._http.request(
|
|
233
|
+
"POST",
|
|
234
|
+
f"/v1/messages/{message_id}/reply",
|
|
235
|
+
body=payload,
|
|
236
|
+
extra_headers=extra_headers,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def get_idempotency_replay(self, key: str) -> "IdempotencyReplayResult":
|
|
240
|
+
"""Probe whether an immediate-send/reply idempotency key already has a
|
|
241
|
+
prior result, WITHOUT side effects (Track 1, §3a).
|
|
242
|
+
|
|
243
|
+
The MCP/CLI attachment wrappers call this BEFORE re-uploading
|
|
244
|
+
attachments (or re-fetching the original message) on a same-key retry,
|
|
245
|
+
so a retry whose local file is gone still replays the prior result
|
|
246
|
+
instead of dying in local preflight.
|
|
247
|
+
|
|
248
|
+
Non-throwing + discriminated: the base client raises on every non-2xx,
|
|
249
|
+
but the load-bearing 404 *miss* (the common "no prior, proceed" case)
|
|
250
|
+
and the three 409 outcomes are caught here and returned as an
|
|
251
|
+
:class:`IdempotencyReplayResult` (``.kind`` one of ``'miss'``,
|
|
252
|
+
``'replay'``, ``'in_flight'``, ``'not_proven_sent'``,
|
|
253
|
+
``'bound_to_draft'``). Every OTHER non-2xx (401/403/500/other-409)
|
|
254
|
+
re-raises the original error.
|
|
255
|
+
|
|
256
|
+
The key travels in the ``Idempotency-Key`` request header (keys are
|
|
257
|
+
arbitrary client strings that may contain ``/``, ``?``, ``#`` — a header
|
|
258
|
+
sidesteps URL-encoding hazards).
|
|
259
|
+
"""
|
|
260
|
+
try:
|
|
261
|
+
message = self._http.request(
|
|
262
|
+
"GET",
|
|
263
|
+
"/v1/messages/idempotency",
|
|
264
|
+
extra_headers={"Idempotency-Key": key},
|
|
265
|
+
)
|
|
266
|
+
except ReplyLayerError as err:
|
|
267
|
+
result = _classify_idempotency_probe_error(err)
|
|
268
|
+
if result is None:
|
|
269
|
+
raise
|
|
270
|
+
return result
|
|
271
|
+
return IdempotencyReplayResult(kind="replay", message=message)
|
|
150
272
|
|
|
151
273
|
def wait(self, mailbox_id: str, *, timeout: int = 30, since: str | None = None) -> dict[str, Any]:
|
|
152
274
|
# RL-UAT-018 — `since` is the monitoring cursor anchor (strict `>`
|
|
@@ -268,6 +390,7 @@ class AsyncMessages:
|
|
|
268
390
|
subaddress_instance_id: str | None = None,
|
|
269
391
|
subaddress_mode: str | None = None,
|
|
270
392
|
attachment_ids: list[str] | None = None,
|
|
393
|
+
idempotency_key: str | None = None,
|
|
271
394
|
) -> dict[str, Any]:
|
|
272
395
|
"""Send an outbound message.
|
|
273
396
|
|
|
@@ -277,6 +400,11 @@ class AsyncMessages:
|
|
|
277
400
|
``from_mailbox``/``subject`` are derived and ``to`` becomes an
|
|
278
401
|
optional participant selector.
|
|
279
402
|
|
|
403
|
+
``idempotency_key`` (Track 1) makes a retried same-key immediate send
|
|
404
|
+
produce at most one email + one charge — the server replays the prior
|
|
405
|
+
message instead of re-sending. The key travels in the ``Idempotency-Key``
|
|
406
|
+
request header. See :meth:`get_idempotency_replay`.
|
|
407
|
+
|
|
280
408
|
Sandbox accounts are subject to a 250-cumulative-send trial
|
|
281
409
|
budget. Once exhausted the API returns 403 with
|
|
282
410
|
``code='SANDBOX_TRIAL_BUDGET_EXHAUSTED'`` and a ``details``
|
|
@@ -301,7 +429,12 @@ class AsyncMessages:
|
|
|
301
429
|
payload["subaddress_mode"] = subaddress_mode
|
|
302
430
|
if attachment_ids is not None:
|
|
303
431
|
payload["attachment_ids"] = attachment_ids
|
|
304
|
-
|
|
432
|
+
extra_headers: dict[str, str] | None = None
|
|
433
|
+
if idempotency_key is not None:
|
|
434
|
+
extra_headers = {"Idempotency-Key": idempotency_key}
|
|
435
|
+
return await self._http.request(
|
|
436
|
+
"POST", "/v1/messages/send", body=payload, extra_headers=extra_headers
|
|
437
|
+
)
|
|
305
438
|
|
|
306
439
|
async def list(
|
|
307
440
|
self,
|
|
@@ -373,7 +506,13 @@ class AsyncMessages:
|
|
|
373
506
|
subaddress_instance_id: str | None = None,
|
|
374
507
|
subaddress_mode: str | None = None,
|
|
375
508
|
attachment_ids: list[str] | None = None,
|
|
509
|
+
idempotency_key: str | None = None,
|
|
376
510
|
) -> dict[str, Any]:
|
|
511
|
+
"""Reply to an inbound message (async). See SyncMessages.reply.
|
|
512
|
+
|
|
513
|
+
``idempotency_key`` (Track 1) makes a retried same-key reply produce at
|
|
514
|
+
most one email + one charge. See :meth:`get_idempotency_replay`.
|
|
515
|
+
"""
|
|
377
516
|
payload: dict[str, Any] = {"body": body}
|
|
378
517
|
if html is not None:
|
|
379
518
|
payload["html"] = html
|
|
@@ -383,7 +522,35 @@ class AsyncMessages:
|
|
|
383
522
|
payload["subaddress_mode"] = subaddress_mode
|
|
384
523
|
if attachment_ids is not None:
|
|
385
524
|
payload["attachment_ids"] = attachment_ids
|
|
386
|
-
|
|
525
|
+
extra_headers: dict[str, str] | None = None
|
|
526
|
+
if idempotency_key is not None:
|
|
527
|
+
extra_headers = {"Idempotency-Key": idempotency_key}
|
|
528
|
+
return await self._http.request(
|
|
529
|
+
"POST",
|
|
530
|
+
f"/v1/messages/{message_id}/reply",
|
|
531
|
+
body=payload,
|
|
532
|
+
extra_headers=extra_headers,
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
async def get_idempotency_replay(self, key: str) -> "IdempotencyReplayResult":
|
|
536
|
+
"""Async probe of an immediate-send/reply idempotency key (Track 1, §3a).
|
|
537
|
+
|
|
538
|
+
Non-throwing + discriminated — see SyncMessages.get_idempotency_replay
|
|
539
|
+
for the full contract. The key travels in the ``Idempotency-Key``
|
|
540
|
+
request header.
|
|
541
|
+
"""
|
|
542
|
+
try:
|
|
543
|
+
message = await self._http.request(
|
|
544
|
+
"GET",
|
|
545
|
+
"/v1/messages/idempotency",
|
|
546
|
+
extra_headers={"Idempotency-Key": key},
|
|
547
|
+
)
|
|
548
|
+
except ReplyLayerError as err:
|
|
549
|
+
result = _classify_idempotency_probe_error(err)
|
|
550
|
+
if result is None:
|
|
551
|
+
raise
|
|
552
|
+
return result
|
|
553
|
+
return IdempotencyReplayResult(kind="replay", message=message)
|
|
387
554
|
|
|
388
555
|
async def wait(self, mailbox_id: str, *, timeout: int = 30, since: str | None = None) -> dict[str, Any]:
|
|
389
556
|
# RL-UAT-018 — `since` is the monitoring cursor anchor (strict `>`
|