replylayer 0.17.0__tar.gz → 0.21.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.17.0 → replylayer-0.21.0}/.gitignore +4 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/PKG-INFO +20 -3
- {replylayer-0.17.0 → replylayer-0.21.0}/README.md +19 -2
- {replylayer-0.17.0 → replylayer-0.21.0}/pyproject.toml +1 -1
- {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/__init__.py +33 -1
- {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/_client.py +11 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/_http.py +9 -1
- {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/errors.py +104 -0
- replylayer-0.21.0/replylayer/resources/account.py +97 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/messages.py +277 -6
- {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/types.py +152 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/tests/test_async.py +105 -1
- {replylayer-0.17.0 → replylayer-0.21.0}/tests/test_domains.py +2 -2
- replylayer-0.21.0/tests/test_governed_email_effect.py +399 -0
- replylayer-0.21.0/tests/test_messages_idempotency.py +285 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/tests/test_resources.py +120 -5
- replylayer-0.21.0/tests/test_version.py +35 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/tests/test_web_risk_types.py +53 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/uv.lock +1 -1
- replylayer-0.17.0/replylayer/resources/account.py +0 -36
- replylayer-0.17.0/tests/test_version.py +0 -18
- {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/__main__.py +0 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/_pagination.py +0 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/py.typed +0 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/__init__.py +0 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/api_keys.py +0 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/attachments.py +0 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/domains.py +0 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/drafts.py +0 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/health.py +0 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/inbound_blocklist.py +0 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/legal_holds.py +0 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/mailboxes.py +0 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/recipients.py +0 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/suppressions.py +0 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/threads.py +0 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/webhooks.py +0 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/tests/__init__.py +0 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/tests/test_attachments.py +0 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/tests/test_client.py +0 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/tests/test_drafts.py +0 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/tests/test_hitl_review_types.py +0 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/tests/test_http.py +0 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/tests/test_threads.py +0 -0
- {replylayer-0.17.0 → replylayer-0.21.0}/tests/test_webhooks.py +0 -0
- {replylayer-0.17.0 → replylayer-0.21.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.21.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
|
|
@@ -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.
|
|
@@ -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
|
|
@@ -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.
|
|
@@ -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,
|
|
@@ -12,8 +13,16 @@ from .errors import (
|
|
|
12
13
|
# Migration 040 — scheduled-send error surface.
|
|
13
14
|
SchedulingError,
|
|
14
15
|
TimezoneRequiredError,
|
|
16
|
+
# Governed Email-Effect Contract v1 (Track 2) — strict-outcome errors.
|
|
17
|
+
EmailEffectError,
|
|
18
|
+
EmailEffectRejectedError,
|
|
19
|
+
EmailEffectHeldError,
|
|
20
|
+
EmailEffectRetryableError,
|
|
15
21
|
)
|
|
16
22
|
from .types import (
|
|
23
|
+
# Message delete response (0.20.0).
|
|
24
|
+
DeleteMessageResponse,
|
|
25
|
+
ReportResponse,
|
|
17
26
|
# WS1 — star response types (0.16.0).
|
|
18
27
|
MessageStarResponse,
|
|
19
28
|
ThreadStarResponse,
|
|
@@ -57,6 +66,13 @@ from .types import (
|
|
|
57
66
|
WebRiskLearnMore,
|
|
58
67
|
WebRiskWarning,
|
|
59
68
|
WebRiskThreatType,
|
|
69
|
+
LinkScanningStatus,
|
|
70
|
+
EnableLinkScanningResponse,
|
|
71
|
+
EnableLinkScanningDisclosure,
|
|
72
|
+
AgentSafetyContext,
|
|
73
|
+
# Governed Email-Effect Contract v1 (Track 2).
|
|
74
|
+
EmailEffect,
|
|
75
|
+
EffectStatus,
|
|
60
76
|
MessageReceivedSafetySignal,
|
|
61
77
|
MessageReceivedWebhookPayload,
|
|
62
78
|
# PR 6 — HITL review queue webhook payload types.
|
|
@@ -76,14 +92,19 @@ from .types import (
|
|
|
76
92
|
ScannerPolicy,
|
|
77
93
|
)
|
|
78
94
|
|
|
79
|
-
__version__ = "0.
|
|
95
|
+
__version__ = "0.21.0"
|
|
80
96
|
|
|
81
97
|
__all__ = [
|
|
98
|
+
# Message delete response (0.20.0).
|
|
99
|
+
"DeleteMessageResponse",
|
|
100
|
+
"ReportResponse",
|
|
82
101
|
# WS1 — star response types (0.16.0).
|
|
83
102
|
"MessageStarResponse",
|
|
84
103
|
"ThreadStarResponse",
|
|
85
104
|
# WS6-SDK — async optimistic-ack (0.16.0).
|
|
86
105
|
"AsyncSendAck",
|
|
106
|
+
# Track 1 — non-throwing idempotency replay probe result.
|
|
107
|
+
"IdempotencyReplayResult",
|
|
87
108
|
"ReplyLayer",
|
|
88
109
|
"AsyncReplyLayer",
|
|
89
110
|
"RetryInfo",
|
|
@@ -98,6 +119,13 @@ __all__ = [
|
|
|
98
119
|
# Migration 040 — scheduled-send.
|
|
99
120
|
"SchedulingError",
|
|
100
121
|
"TimezoneRequiredError",
|
|
122
|
+
# Governed Email-Effect Contract v1 (Track 2).
|
|
123
|
+
"EmailEffectError",
|
|
124
|
+
"EmailEffectRejectedError",
|
|
125
|
+
"EmailEffectHeldError",
|
|
126
|
+
"EmailEffectRetryableError",
|
|
127
|
+
"EmailEffect",
|
|
128
|
+
"EffectStatus",
|
|
101
129
|
"WebhookSummary",
|
|
102
130
|
"WebhookDeliverySummary",
|
|
103
131
|
"WebhookDeliveryStatus",
|
|
@@ -132,6 +160,10 @@ __all__ = [
|
|
|
132
160
|
"WebRiskLearnMore",
|
|
133
161
|
"WebRiskWarning",
|
|
134
162
|
"WebRiskThreatType",
|
|
163
|
+
"LinkScanningStatus",
|
|
164
|
+
"EnableLinkScanningResponse",
|
|
165
|
+
"EnableLinkScanningDisclosure",
|
|
166
|
+
"AgentSafetyContext",
|
|
135
167
|
"MessageReceivedSafetySignal",
|
|
136
168
|
"MessageReceivedWebhookPayload",
|
|
137
169
|
# PR 6 — HITL review queue.
|
|
@@ -36,6 +36,7 @@ class ReplyLayer:
|
|
|
36
36
|
timeout: float = 30.0,
|
|
37
37
|
max_retry_after_seconds: float = _MAX_RETRY_AFTER_SECONDS,
|
|
38
38
|
on_retry: OnRetry | None = None,
|
|
39
|
+
strict_outcome: bool = False,
|
|
39
40
|
) -> None:
|
|
40
41
|
if not api_key:
|
|
41
42
|
raise ReplyLayerError(
|
|
@@ -43,6 +44,12 @@ class ReplyLayer:
|
|
|
43
44
|
"api_key is required. Get yours at https://app.replylayer.ai/connect",
|
|
44
45
|
)
|
|
45
46
|
|
|
47
|
+
# Governed Email-Effect Contract v1 (Track 2) — client-default for the
|
|
48
|
+
# `Prefer: outcome=strict` opt-in. When True, messages.send / .reply
|
|
49
|
+
# request a non-2xx (503/422/409) for a held/blocked outcome, raising the
|
|
50
|
+
# matching EmailEffectError subclass instead of resolving a 200. Default
|
|
51
|
+
# False is byte-identical to today (200 with `email_effect` in the body,
|
|
52
|
+
# no throw). Override per-call via `messages.send(strict_outcome=...)`.
|
|
46
53
|
self._http = SyncHttpClient(
|
|
47
54
|
api_key=api_key,
|
|
48
55
|
base_url=base_url.rstrip("/"),
|
|
@@ -50,6 +57,7 @@ class ReplyLayer:
|
|
|
50
57
|
timeout=timeout,
|
|
51
58
|
max_retry_after_seconds=max_retry_after_seconds,
|
|
52
59
|
on_retry=on_retry,
|
|
60
|
+
strict_outcome=strict_outcome,
|
|
53
61
|
)
|
|
54
62
|
|
|
55
63
|
self.domains = SyncDomains(self._http)
|
|
@@ -87,6 +95,7 @@ class AsyncReplyLayer:
|
|
|
87
95
|
timeout: float = 30.0,
|
|
88
96
|
max_retry_after_seconds: float = _MAX_RETRY_AFTER_SECONDS,
|
|
89
97
|
on_retry: OnRetry | None = None,
|
|
98
|
+
strict_outcome: bool = False,
|
|
90
99
|
) -> None:
|
|
91
100
|
if not api_key:
|
|
92
101
|
raise ReplyLayerError(
|
|
@@ -94,6 +103,7 @@ class AsyncReplyLayer:
|
|
|
94
103
|
"api_key is required. Get yours at https://app.replylayer.ai/connect",
|
|
95
104
|
)
|
|
96
105
|
|
|
106
|
+
# Governed Email-Effect Contract v1 (Track 2) — see ReplyLayer above.
|
|
97
107
|
self._http = AsyncHttpClient(
|
|
98
108
|
api_key=api_key,
|
|
99
109
|
base_url=base_url.rstrip("/"),
|
|
@@ -101,6 +111,7 @@ class AsyncReplyLayer:
|
|
|
101
111
|
timeout=timeout,
|
|
102
112
|
max_retry_after_seconds=max_retry_after_seconds,
|
|
103
113
|
on_retry=on_retry,
|
|
114
|
+
strict_outcome=strict_outcome,
|
|
104
115
|
)
|
|
105
116
|
|
|
106
117
|
self.domains = AsyncDomains(self._http)
|
|
@@ -9,7 +9,7 @@ import httpx
|
|
|
9
9
|
|
|
10
10
|
from .errors import ReplyLayerError, error_from_response
|
|
11
11
|
|
|
12
|
-
_VERSION = "0.
|
|
12
|
+
_VERSION = "0.21.0"
|
|
13
13
|
_USER_AGENT = f"replylayer-sdk-py/{_VERSION}"
|
|
14
14
|
_PROTECTED_HEADER_KEYS = frozenset({"authorization", "content-type", "user-agent"})
|
|
15
15
|
|
|
@@ -47,6 +47,7 @@ class SyncHttpClient:
|
|
|
47
47
|
timeout: float,
|
|
48
48
|
max_retry_after_seconds: float = _MAX_RETRY_AFTER_SECONDS,
|
|
49
49
|
on_retry: OnRetry | None = None,
|
|
50
|
+
strict_outcome: bool = False,
|
|
50
51
|
) -> None:
|
|
51
52
|
self._api_key = api_key
|
|
52
53
|
self._base_url = base_url
|
|
@@ -54,6 +55,10 @@ class SyncHttpClient:
|
|
|
54
55
|
self._timeout = timeout
|
|
55
56
|
self._max_retry_after_seconds = max_retry_after_seconds
|
|
56
57
|
self._on_retry = on_retry
|
|
58
|
+
# Governed Email-Effect Contract v1 (Track 2) — client default for the
|
|
59
|
+
# `Prefer: outcome=strict` opt-in. Read by SyncMessages/AsyncMessages
|
|
60
|
+
# send/reply as the fallback when the per-call `strict_outcome` is None.
|
|
61
|
+
self._strict_outcome = strict_outcome
|
|
57
62
|
self._client = httpx.Client(timeout=timeout)
|
|
58
63
|
|
|
59
64
|
def close(self) -> None:
|
|
@@ -194,6 +199,7 @@ class AsyncHttpClient:
|
|
|
194
199
|
timeout: float,
|
|
195
200
|
max_retry_after_seconds: float = _MAX_RETRY_AFTER_SECONDS,
|
|
196
201
|
on_retry: OnRetry | None = None,
|
|
202
|
+
strict_outcome: bool = False,
|
|
197
203
|
) -> None:
|
|
198
204
|
self._api_key = api_key
|
|
199
205
|
self._base_url = base_url
|
|
@@ -201,6 +207,8 @@ class AsyncHttpClient:
|
|
|
201
207
|
self._timeout = timeout
|
|
202
208
|
self._max_retry_after_seconds = max_retry_after_seconds
|
|
203
209
|
self._on_retry = on_retry
|
|
210
|
+
# Governed Email-Effect Contract v1 (Track 2) — see SyncHttpClient.
|
|
211
|
+
self._strict_outcome = strict_outcome
|
|
204
212
|
self._client = httpx.AsyncClient(timeout=timeout)
|
|
205
213
|
|
|
206
214
|
async def aclose(self) -> None:
|
|
@@ -113,6 +113,92 @@ class TimezoneRequiredError(SchedulingError):
|
|
|
113
113
|
)
|
|
114
114
|
|
|
115
115
|
|
|
116
|
+
# Governed Email-Effect Contract v1 (Track 2) — strict-outcome opt-in errors.
|
|
117
|
+
#
|
|
118
|
+
# Raised ONLY when the caller opted in via `strict_outcome=True`
|
|
119
|
+
# (Prefer: outcome=strict) AND the server maps a non-delivered terminal/held
|
|
120
|
+
# outcome to a non-2xx. The error body carries the full governed payload
|
|
121
|
+
# (`message_id`, `status`, `scan`, `hold_context`, `email_effect`) in
|
|
122
|
+
# `details`, so a caller can branch retry-vs-edit-vs-escalate from the throw.
|
|
123
|
+
#
|
|
124
|
+
# Default (no `strict_outcome`) is byte-identical to today: the send/reply path
|
|
125
|
+
# returns HTTP 200 with `email_effect` in the body and NONE of these is raised.
|
|
126
|
+
#
|
|
127
|
+
# Subclass of ReplyLayerError so generic catches still work; isinstance
|
|
128
|
+
# EmailEffectError narrows to the strict-outcome family. Status-code → subclass:
|
|
129
|
+
# 503 EMAIL_EFFECT_HELD_INFRA → EmailEffectRetryableError (carries retry_after)
|
|
130
|
+
# 422 EMAIL_EFFECT_REJECTED → EmailEffectRejectedError (terminal content block)
|
|
131
|
+
# 409 EMAIL_EFFECT_HELD → EmailEffectHeldError (releasable hold)
|
|
132
|
+
# The factory recognizes the family by the body carrying `details.email_effect`
|
|
133
|
+
# (mirroring the TS SDK), not by the `code` string.
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class EmailEffectError(ReplyLayerError):
|
|
137
|
+
"""Base for the strict-outcome (``Prefer: outcome=strict``) error family.
|
|
138
|
+
|
|
139
|
+
``email_effect`` is the parsed ``details.email_effect`` block (the governed
|
|
140
|
+
discriminator + booleans); ``scan`` is the parsed ``details.scan`` summary.
|
|
141
|
+
Both may be None if the server omitted them.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
status_code: int,
|
|
147
|
+
code: str,
|
|
148
|
+
message: str,
|
|
149
|
+
details: dict[str, Any] | None = None,
|
|
150
|
+
) -> None:
|
|
151
|
+
super().__init__(status_code, code, message, details)
|
|
152
|
+
d = details or {}
|
|
153
|
+
self.email_effect: dict[str, Any] | None = d.get("email_effect")
|
|
154
|
+
self.scan: dict[str, Any] | None = d.get("scan")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class EmailEffectRejectedError(EmailEffectError):
|
|
158
|
+
"""422 EMAIL_EFFECT_REJECTED — a genuine terminal content rejection.
|
|
159
|
+
|
|
160
|
+
``email_effect.effect_status == 'blocked'`` (terminal=True). The agent's
|
|
161
|
+
correct action is edit-and-resend or escalate — NEVER retry the same body.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
|
|
165
|
+
super().__init__(422, code, message, details)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class EmailEffectHeldError(EmailEffectError):
|
|
169
|
+
"""409 EMAIL_EFFECT_HELD — accepted into governance, releasable.
|
|
170
|
+
|
|
171
|
+
``email_effect.effect_status == 'held_for_review'`` (releasable=True). The
|
|
172
|
+
send was quarantined / queued for human review — report "awaiting approval",
|
|
173
|
+
do not treat as a content error to fix by editing.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
|
|
177
|
+
super().__init__(409, code, message, details)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class EmailEffectRetryableError(EmailEffectError):
|
|
181
|
+
"""503 EMAIL_EFFECT_HELD_INFRA — a transient infrastructure hold.
|
|
182
|
+
|
|
183
|
+
``email_effect.effect_status == 'held_infrastructure'`` (retryable=True): an
|
|
184
|
+
``inference_error`` with NO genuine model judgment, so the content was never
|
|
185
|
+
judged. The agent's correct action is retry-later (back off on repeated
|
|
186
|
+
503s). ``retry_after`` mirrors the ``Retry-After`` header (None on absent /
|
|
187
|
+
non-numeric), reusing the same null-on-NaN parse as RateLimitError.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
def __init__(
|
|
191
|
+
self,
|
|
192
|
+
code: str,
|
|
193
|
+
message: str,
|
|
194
|
+
headers: dict[str, str],
|
|
195
|
+
details: dict[str, Any] | None = None,
|
|
196
|
+
) -> None:
|
|
197
|
+
super().__init__(503, code, message, details)
|
|
198
|
+
ra = headers.get("retry-after")
|
|
199
|
+
self.retry_after: int | None = int(ra) if ra and ra.isdigit() else None
|
|
200
|
+
|
|
201
|
+
|
|
116
202
|
def error_from_response(
|
|
117
203
|
status: int,
|
|
118
204
|
body: dict[str, Any],
|
|
@@ -139,6 +225,24 @@ def error_from_response(
|
|
|
139
225
|
if code in _SCHEDULING_REASON_CODES:
|
|
140
226
|
return SchedulingError(status, code, message, details)
|
|
141
227
|
|
|
228
|
+
# Governed Email-Effect Contract v1 (Track 2) — strict-outcome opt-in.
|
|
229
|
+
# Precedence-first BEFORE the generic status switch so a 503 becomes an
|
|
230
|
+
# EmailEffectRetryableError (not a generic ReplyLayerError), a 422 an
|
|
231
|
+
# EmailEffectRejectedError (not a plain ValidationError), and a 409 an
|
|
232
|
+
# EmailEffectHeldError — so `isinstance(err, EmailEffectError)` works
|
|
233
|
+
# uniformly across statuses. Gated on the body carrying `email_effect`
|
|
234
|
+
# (present ONLY when the request opted in AND the server applied the strict
|
|
235
|
+
# mapping), mirroring the TS SDK's `details?.email_effect` gate — robust to
|
|
236
|
+
# the exact `code` string. Any other status falls through to the generic
|
|
237
|
+
# switch unchanged.
|
|
238
|
+
if isinstance(details, dict) and details.get("email_effect") is not None:
|
|
239
|
+
if status == 422:
|
|
240
|
+
return EmailEffectRejectedError(code, message, details)
|
|
241
|
+
if status == 409:
|
|
242
|
+
return EmailEffectHeldError(code, message, details)
|
|
243
|
+
if status == 503:
|
|
244
|
+
return EmailEffectRetryableError(code, message, headers, details)
|
|
245
|
+
|
|
142
246
|
if status == 401:
|
|
143
247
|
return AuthenticationError(code, message, details)
|
|
144
248
|
if status == 403:
|
|
@@ -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")
|