replylayer 0.18.0__tar.gz → 0.22.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.18.0 → replylayer-0.22.0}/PKG-INFO +23 -1
- {replylayer-0.18.0 → replylayer-0.22.0}/README.md +22 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/pyproject.toml +1 -1
- {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/__init__.py +38 -1
- {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/_client.py +11 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/_http.py +9 -1
- {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/errors.py +104 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/mailboxes.py +101 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/messages.py +117 -13
- {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/types.py +177 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_async.py +91 -0
- replylayer-0.22.0/tests/test_governed_email_effect.py +399 -0
- replylayer-0.22.0/tests/test_instruction_trust.py +37 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_resources.py +97 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/.gitignore +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/__main__.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/_pagination.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/py.typed +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/__init__.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/account.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/api_keys.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/attachments.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/domains.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/drafts.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/health.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/inbound_blocklist.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/legal_holds.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/recipients.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/suppressions.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/threads.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/webhooks.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/tests/__init__.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_attachments.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_client.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_domains.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_drafts.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_hitl_review_types.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_http.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_messages_idempotency.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_threads.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_version.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_web_risk_types.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_webhooks.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_ws1_ws6.py +0 -0
- {replylayer-0.18.0 → replylayer-0.22.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: replylayer
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.22.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
|
|
@@ -443,6 +443,28 @@ if not status["active"] and status["privacy_ok"]:
|
|
|
443
443
|
|
|
444
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
445
|
|
|
446
|
+
## Trusted instruction sources
|
|
447
|
+
|
|
448
|
+
Every inbound message read carries `agent_safety_context` with `untrusted_content` `True` — the body is external data, not instructions for the agent to act on. Trusted instruction sources is an opt-in, **read-path-only** relaxation of that default for one specific, customer-designated, *verified* sender address per mailbox (address-grain only — there is no domain-wide grant). It does not change what a resulting send is allowed to do; the only related send-side control is a per-mailbox strict-recipient toggle, configured outside the SDK.
|
|
449
|
+
|
|
450
|
+
The relaxation only applies on a read when **every** layer is satisfied, entirely on the server side: the platform feature is enabled, the mailbox's instruction-trust mode is on, the designated sender's message passed Mailgun sender verification (`verified_aligned`), the message scanned clean and is `available`, and the reading API key is a `role="agent"` key with the per-key capability enabled. There is no client-side opt-in — the SDK does not send any header or constructor flag to request this; a message either qualifies under the operator's configuration or it doesn't, and the response reflects that automatically:
|
|
451
|
+
|
|
452
|
+
```python
|
|
453
|
+
result = rl.messages.wait(mailbox["id"])
|
|
454
|
+
msg = result["message"]
|
|
455
|
+
ctx = msg.get("agent_safety_context") if msg else None
|
|
456
|
+
if ctx and ctx.get("instruction_trust"):
|
|
457
|
+
# Gate passed: guidance was REPLACED with trusted-instruction wording (the
|
|
458
|
+
# agent may act on this verified sender's own explicit request in this
|
|
459
|
+
# message). untrusted_content is still True — this is metadata, not a
|
|
460
|
+
# content-safety judgment.
|
|
461
|
+
print(ctx["guidance"])
|
|
462
|
+
print(ctx["instruction_trust"])
|
|
463
|
+
# {"version": "v1", "match": "address", "verified_domain": ..., "verdict": "verified_aligned", "provenance": "mailgun"}
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
A human account owner enables the mailbox mode and the key's capability, and designates the trusted sender, from the dashboard (each a loosening change requiring session re-auth). Both the mailbox mode and the per-key capability default to off, so existing integrations are unaffected until a customer opts in. This is a read-path signal only — a copied or hijacked API key cannot self-grant the capability, and there is nothing for a client to set to request it.
|
|
467
|
+
|
|
446
468
|
## Mailbox identifiers
|
|
447
469
|
|
|
448
470
|
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.
|
|
@@ -426,6 +426,28 @@ if not status["active"] and status["privacy_ok"]:
|
|
|
426
426
|
|
|
427
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
428
|
|
|
429
|
+
## Trusted instruction sources
|
|
430
|
+
|
|
431
|
+
Every inbound message read carries `agent_safety_context` with `untrusted_content` `True` — the body is external data, not instructions for the agent to act on. Trusted instruction sources is an opt-in, **read-path-only** relaxation of that default for one specific, customer-designated, *verified* sender address per mailbox (address-grain only — there is no domain-wide grant). It does not change what a resulting send is allowed to do; the only related send-side control is a per-mailbox strict-recipient toggle, configured outside the SDK.
|
|
432
|
+
|
|
433
|
+
The relaxation only applies on a read when **every** layer is satisfied, entirely on the server side: the platform feature is enabled, the mailbox's instruction-trust mode is on, the designated sender's message passed Mailgun sender verification (`verified_aligned`), the message scanned clean and is `available`, and the reading API key is a `role="agent"` key with the per-key capability enabled. There is no client-side opt-in — the SDK does not send any header or constructor flag to request this; a message either qualifies under the operator's configuration or it doesn't, and the response reflects that automatically:
|
|
434
|
+
|
|
435
|
+
```python
|
|
436
|
+
result = rl.messages.wait(mailbox["id"])
|
|
437
|
+
msg = result["message"]
|
|
438
|
+
ctx = msg.get("agent_safety_context") if msg else None
|
|
439
|
+
if ctx and ctx.get("instruction_trust"):
|
|
440
|
+
# Gate passed: guidance was REPLACED with trusted-instruction wording (the
|
|
441
|
+
# agent may act on this verified sender's own explicit request in this
|
|
442
|
+
# message). untrusted_content is still True — this is metadata, not a
|
|
443
|
+
# content-safety judgment.
|
|
444
|
+
print(ctx["guidance"])
|
|
445
|
+
print(ctx["instruction_trust"])
|
|
446
|
+
# {"version": "v1", "match": "address", "verified_domain": ..., "verdict": "verified_aligned", "provenance": "mailgun"}
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
A human account owner enables the mailbox mode and the key's capability, and designates the trusted sender, from the dashboard (each a loosening change requiring session re-auth). Both the mailbox mode and the per-key capability default to off, so existing integrations are unaffected until a customer opts in. This is a read-path signal only — a copied or hijacked API key cannot self-grant the capability, and there is nothing for a client to set to request it.
|
|
450
|
+
|
|
429
451
|
## Mailbox identifiers
|
|
430
452
|
|
|
431
453
|
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.
|
|
@@ -13,13 +13,22 @@ from .errors import (
|
|
|
13
13
|
# Migration 040 — scheduled-send error surface.
|
|
14
14
|
SchedulingError,
|
|
15
15
|
TimezoneRequiredError,
|
|
16
|
+
# Governed Email-Effect Contract v1 (Track 2) — strict-outcome errors.
|
|
17
|
+
EmailEffectError,
|
|
18
|
+
EmailEffectRejectedError,
|
|
19
|
+
EmailEffectHeldError,
|
|
20
|
+
EmailEffectRetryableError,
|
|
16
21
|
)
|
|
17
22
|
from .types import (
|
|
23
|
+
# Message delete response (0.20.0).
|
|
24
|
+
DeleteMessageResponse,
|
|
25
|
+
ReportResponse,
|
|
18
26
|
# WS1 — star response types (0.16.0).
|
|
19
27
|
MessageStarResponse,
|
|
20
28
|
ThreadStarResponse,
|
|
21
29
|
# WS6-SDK — async optimistic-ack (0.16.0).
|
|
22
30
|
AsyncSendAck,
|
|
31
|
+
WebhookEventType,
|
|
23
32
|
WebhookSummary,
|
|
24
33
|
WebhookDeliverySummary,
|
|
25
34
|
WebhookDeliveryStatus,
|
|
@@ -41,6 +50,9 @@ from .types import (
|
|
|
41
50
|
GetUploadAttachmentResponse,
|
|
42
51
|
# Migration 036 — recipient allowlist types.
|
|
43
52
|
RecipientPolicyMode,
|
|
53
|
+
# Single "agent sends" control — derived view types.
|
|
54
|
+
AgentSendPolicy,
|
|
55
|
+
AgentSendRestrictedBy,
|
|
44
56
|
AllowlistEntry,
|
|
45
57
|
ListAllowlistResponse,
|
|
46
58
|
AddAllowlistResponse,
|
|
@@ -62,6 +74,14 @@ from .types import (
|
|
|
62
74
|
EnableLinkScanningResponse,
|
|
63
75
|
EnableLinkScanningDisclosure,
|
|
64
76
|
AgentSafetyContext,
|
|
77
|
+
InstructionTrustBasis,
|
|
78
|
+
# R5 — verified-sender signal.
|
|
79
|
+
SenderAuthVerdict,
|
|
80
|
+
SenderAuthentication,
|
|
81
|
+
SenderAuthenticationCompact,
|
|
82
|
+
# Governed Email-Effect Contract v1 (Track 2).
|
|
83
|
+
EmailEffect,
|
|
84
|
+
EffectStatus,
|
|
65
85
|
MessageReceivedSafetySignal,
|
|
66
86
|
MessageReceivedWebhookPayload,
|
|
67
87
|
# PR 6 — HITL review queue webhook payload types.
|
|
@@ -81,9 +101,12 @@ from .types import (
|
|
|
81
101
|
ScannerPolicy,
|
|
82
102
|
)
|
|
83
103
|
|
|
84
|
-
__version__ = "0.
|
|
104
|
+
__version__ = "0.22.0"
|
|
85
105
|
|
|
86
106
|
__all__ = [
|
|
107
|
+
# Message delete response (0.20.0).
|
|
108
|
+
"DeleteMessageResponse",
|
|
109
|
+
"ReportResponse",
|
|
87
110
|
# WS1 — star response types (0.16.0).
|
|
88
111
|
"MessageStarResponse",
|
|
89
112
|
"ThreadStarResponse",
|
|
@@ -105,6 +128,14 @@ __all__ = [
|
|
|
105
128
|
# Migration 040 — scheduled-send.
|
|
106
129
|
"SchedulingError",
|
|
107
130
|
"TimezoneRequiredError",
|
|
131
|
+
# Governed Email-Effect Contract v1 (Track 2).
|
|
132
|
+
"EmailEffectError",
|
|
133
|
+
"EmailEffectRejectedError",
|
|
134
|
+
"EmailEffectHeldError",
|
|
135
|
+
"EmailEffectRetryableError",
|
|
136
|
+
"EmailEffect",
|
|
137
|
+
"EffectStatus",
|
|
138
|
+
"WebhookEventType",
|
|
108
139
|
"WebhookSummary",
|
|
109
140
|
"WebhookDeliverySummary",
|
|
110
141
|
"WebhookDeliveryStatus",
|
|
@@ -125,6 +156,8 @@ __all__ = [
|
|
|
125
156
|
"ConsumedAttachmentResponse",
|
|
126
157
|
"GetUploadAttachmentResponse",
|
|
127
158
|
"RecipientPolicyMode",
|
|
159
|
+
"AgentSendPolicy",
|
|
160
|
+
"AgentSendRestrictedBy",
|
|
128
161
|
"AllowlistEntry",
|
|
129
162
|
"ListAllowlistResponse",
|
|
130
163
|
"AddAllowlistResponse",
|
|
@@ -143,6 +176,10 @@ __all__ = [
|
|
|
143
176
|
"EnableLinkScanningResponse",
|
|
144
177
|
"EnableLinkScanningDisclosure",
|
|
145
178
|
"AgentSafetyContext",
|
|
179
|
+
"InstructionTrustBasis",
|
|
180
|
+
"SenderAuthVerdict",
|
|
181
|
+
"SenderAuthentication",
|
|
182
|
+
"SenderAuthenticationCompact",
|
|
146
183
|
"MessageReceivedSafetySignal",
|
|
147
184
|
"MessageReceivedWebhookPayload",
|
|
148
185
|
# 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.22.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:
|
|
@@ -5,6 +5,7 @@ from urllib.parse import quote
|
|
|
5
5
|
|
|
6
6
|
from .._http import AsyncHttpClient, SyncHttpClient
|
|
7
7
|
from ..types import (
|
|
8
|
+
AgentSendPolicy,
|
|
8
9
|
AttachmentAccessResponse,
|
|
9
10
|
AttachmentAllowedFileFamilyRequest,
|
|
10
11
|
AttachmentExposureMode,
|
|
@@ -456,6 +457,16 @@ class SyncMailboxes:
|
|
|
456
457
|
# Migration 085 — thread-scoped reply bypass toggle. None means "do not
|
|
457
458
|
# include the field in the PATCH".
|
|
458
459
|
allow_thread_replies: bool | None = None,
|
|
460
|
+
# Migration 096 — R3 agent-send-containment opt-out (session/admin-only).
|
|
461
|
+
# None means "do not include the field in the PATCH".
|
|
462
|
+
agent_send_containment: bool | None = None,
|
|
463
|
+
# Single "agent sends" convenience control. None means "do not include
|
|
464
|
+
# the field". Mutually exclusive with raw recipient_policy_mode/
|
|
465
|
+
# agent_send_containment in the same call (the server returns 400).
|
|
466
|
+
agent_send_policy: AgentSendPolicy | None = None,
|
|
467
|
+
# Consent for opening the agent on an allowlist mailbox (also opens
|
|
468
|
+
# human sends). None/False omits it.
|
|
469
|
+
confirm_open_human_sends: bool | None = None,
|
|
459
470
|
# PR 8.1 — per-detector redaction visibility. Pass {} to reset to
|
|
460
471
|
# platform default; pass a partial map (e.g.
|
|
461
472
|
# {"EMAIL_ADDRESS": {"redact": False}}) to disable redaction on
|
|
@@ -479,6 +490,12 @@ class SyncMailboxes:
|
|
|
479
490
|
body["hitl_mode"] = hitl_mode
|
|
480
491
|
if allow_thread_replies is not None:
|
|
481
492
|
body["allow_thread_replies"] = allow_thread_replies
|
|
493
|
+
if agent_send_containment is not None:
|
|
494
|
+
body["agent_send_containment"] = agent_send_containment
|
|
495
|
+
if agent_send_policy is not None:
|
|
496
|
+
body["agent_send_policy"] = agent_send_policy
|
|
497
|
+
if confirm_open_human_sends:
|
|
498
|
+
body["confirm_open_human_sends"] = True
|
|
482
499
|
if pii_redaction_config is not None:
|
|
483
500
|
body["pii_redaction_config"] = pii_redaction_config
|
|
484
501
|
return self._http.request("PATCH", f"/v1/mailboxes/{id}", body=body)
|
|
@@ -515,6 +532,41 @@ class SyncMailboxes:
|
|
|
515
532
|
"""
|
|
516
533
|
return self.update(id, allow_thread_replies=allow)
|
|
517
534
|
|
|
535
|
+
def set_agent_send_containment(self, id: str, enabled: bool) -> dict[str, Any]:
|
|
536
|
+
"""Migration 096 — toggle R3 agent-send-containment for this mailbox.
|
|
537
|
+
|
|
538
|
+
When ``True`` (the default), an agent-key send (NOT human/admin/session)
|
|
539
|
+
on a native-blocklist mailbox is run through the allowlist +
|
|
540
|
+
thread-participant gate so a hijacked agent can't exfiltrate to a new
|
|
541
|
+
recipient. Set ``False`` to deliberately open an uncontained agent send
|
|
542
|
+
path. Session/admin only server-side.
|
|
543
|
+
"""
|
|
544
|
+
return self.update(id, agent_send_containment=enabled)
|
|
545
|
+
|
|
546
|
+
def set_agent_send_policy(
|
|
547
|
+
self,
|
|
548
|
+
id: str,
|
|
549
|
+
policy: AgentSendPolicy,
|
|
550
|
+
*,
|
|
551
|
+
confirm_open_human_sends: bool = False,
|
|
552
|
+
) -> dict[str, Any]:
|
|
553
|
+
"""Single "agent sends" control — the thin front door over
|
|
554
|
+
``recipient_policy_mode`` + ``agent_send_containment`` (no separate
|
|
555
|
+
stored field). ``"open"`` lets the agent send to any new recipient;
|
|
556
|
+
``"restricted"`` gates it. Session/admin only server-side.
|
|
557
|
+
|
|
558
|
+
Opening on an allowlist mailbox also opens human sends, so the server
|
|
559
|
+
returns 409 ``OPEN_AGENT_REQUIRES_OPEN_MAILBOX`` (with
|
|
560
|
+
``details.also_opens_human_sends = True``) on the first call. Re-issue
|
|
561
|
+
with ``confirm_open_human_sends=True`` to atomically flip the mailbox to
|
|
562
|
+
blocklist + containment off.
|
|
563
|
+
"""
|
|
564
|
+
return self.update(
|
|
565
|
+
id,
|
|
566
|
+
agent_send_policy=policy,
|
|
567
|
+
confirm_open_human_sends=confirm_open_human_sends,
|
|
568
|
+
)
|
|
569
|
+
|
|
518
570
|
def set_sender_policy(
|
|
519
571
|
self,
|
|
520
572
|
id: str,
|
|
@@ -648,6 +700,16 @@ class AsyncMailboxes:
|
|
|
648
700
|
# Migration 085 — thread-scoped reply bypass toggle. None = do not
|
|
649
701
|
# include in the PATCH.
|
|
650
702
|
allow_thread_replies: bool | None = None,
|
|
703
|
+
# Migration 096 — R3 agent-send-containment opt-out (session/admin-only).
|
|
704
|
+
# None = do not include in the PATCH.
|
|
705
|
+
agent_send_containment: bool | None = None,
|
|
706
|
+
# Single "agent sends" convenience control. None = do not include.
|
|
707
|
+
# Mutually exclusive with raw recipient_policy_mode/agent_send_containment
|
|
708
|
+
# in the same call (the server returns 400).
|
|
709
|
+
agent_send_policy: AgentSendPolicy | None = None,
|
|
710
|
+
# Consent for opening the agent on an allowlist mailbox (also opens
|
|
711
|
+
# human sends). None/False omits it.
|
|
712
|
+
confirm_open_human_sends: bool | None = None,
|
|
651
713
|
# PR 8.1 — per-detector redaction visibility. Pass {} to reset to
|
|
652
714
|
# platform default; pass a partial map to disable redaction on
|
|
653
715
|
# selected detectors. None = do not include in the PATCH.
|
|
@@ -668,6 +730,12 @@ class AsyncMailboxes:
|
|
|
668
730
|
body["hitl_mode"] = hitl_mode
|
|
669
731
|
if allow_thread_replies is not None:
|
|
670
732
|
body["allow_thread_replies"] = allow_thread_replies
|
|
733
|
+
if agent_send_containment is not None:
|
|
734
|
+
body["agent_send_containment"] = agent_send_containment
|
|
735
|
+
if agent_send_policy is not None:
|
|
736
|
+
body["agent_send_policy"] = agent_send_policy
|
|
737
|
+
if confirm_open_human_sends:
|
|
738
|
+
body["confirm_open_human_sends"] = True
|
|
671
739
|
if pii_redaction_config is not None:
|
|
672
740
|
body["pii_redaction_config"] = pii_redaction_config
|
|
673
741
|
return await self._http.request("PATCH", f"/v1/mailboxes/{id}", body=body)
|
|
@@ -706,6 +774,39 @@ class AsyncMailboxes:
|
|
|
706
774
|
"""
|
|
707
775
|
return await self.update(id, allow_thread_replies=allow)
|
|
708
776
|
|
|
777
|
+
async def set_agent_send_containment(self, id: str, enabled: bool) -> dict[str, Any]:
|
|
778
|
+
"""Migration 096 — toggle R3 agent-send-containment for this mailbox (async).
|
|
779
|
+
|
|
780
|
+
When ``True`` (the default), an agent-key send on a native-blocklist
|
|
781
|
+
mailbox is run through the allowlist + thread-participant gate. Set
|
|
782
|
+
``False`` to deliberately open an uncontained agent send path.
|
|
783
|
+
Session/admin only server-side.
|
|
784
|
+
"""
|
|
785
|
+
return await self.update(id, agent_send_containment=enabled)
|
|
786
|
+
|
|
787
|
+
async def set_agent_send_policy(
|
|
788
|
+
self,
|
|
789
|
+
id: str,
|
|
790
|
+
policy: AgentSendPolicy,
|
|
791
|
+
*,
|
|
792
|
+
confirm_open_human_sends: bool = False,
|
|
793
|
+
) -> dict[str, Any]:
|
|
794
|
+
"""Single "agent sends" control — the thin front door over
|
|
795
|
+
``recipient_policy_mode`` + ``agent_send_containment`` (async).
|
|
796
|
+
|
|
797
|
+
``"open"`` lets the agent send to any new recipient; ``"restricted"``
|
|
798
|
+
gates it. Session/admin only server-side. Opening on an allowlist
|
|
799
|
+
mailbox also opens human sends, so the server returns 409
|
|
800
|
+
``OPEN_AGENT_REQUIRES_OPEN_MAILBOX``; re-issue with
|
|
801
|
+
``confirm_open_human_sends=True`` to atomically flip to blocklist +
|
|
802
|
+
containment off.
|
|
803
|
+
"""
|
|
804
|
+
return await self.update(
|
|
805
|
+
id,
|
|
806
|
+
agent_send_policy=policy,
|
|
807
|
+
confirm_open_human_sends=confirm_open_human_sends,
|
|
808
|
+
)
|
|
809
|
+
|
|
709
810
|
async def set_sender_policy(
|
|
710
811
|
self,
|
|
711
812
|
id: str,
|