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.
Files changed (45) hide show
  1. {replylayer-0.18.0 → replylayer-0.22.0}/PKG-INFO +23 -1
  2. {replylayer-0.18.0 → replylayer-0.22.0}/README.md +22 -0
  3. {replylayer-0.18.0 → replylayer-0.22.0}/pyproject.toml +1 -1
  4. {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/__init__.py +38 -1
  5. {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/_client.py +11 -0
  6. {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/_http.py +9 -1
  7. {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/errors.py +104 -0
  8. {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/mailboxes.py +101 -0
  9. {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/messages.py +117 -13
  10. {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/types.py +177 -0
  11. {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_async.py +91 -0
  12. replylayer-0.22.0/tests/test_governed_email_effect.py +399 -0
  13. replylayer-0.22.0/tests/test_instruction_trust.py +37 -0
  14. {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_resources.py +97 -0
  15. {replylayer-0.18.0 → replylayer-0.22.0}/.gitignore +0 -0
  16. {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/__main__.py +0 -0
  17. {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/_pagination.py +0 -0
  18. {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/py.typed +0 -0
  19. {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/__init__.py +0 -0
  20. {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/account.py +0 -0
  21. {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/api_keys.py +0 -0
  22. {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/attachments.py +0 -0
  23. {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/domains.py +0 -0
  24. {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/drafts.py +0 -0
  25. {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/health.py +0 -0
  26. {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/inbound_blocklist.py +0 -0
  27. {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/legal_holds.py +0 -0
  28. {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/recipients.py +0 -0
  29. {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/suppressions.py +0 -0
  30. {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/threads.py +0 -0
  31. {replylayer-0.18.0 → replylayer-0.22.0}/replylayer/resources/webhooks.py +0 -0
  32. {replylayer-0.18.0 → replylayer-0.22.0}/tests/__init__.py +0 -0
  33. {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_attachments.py +0 -0
  34. {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_client.py +0 -0
  35. {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_domains.py +0 -0
  36. {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_drafts.py +0 -0
  37. {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_hitl_review_types.py +0 -0
  38. {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_http.py +0 -0
  39. {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_messages_idempotency.py +0 -0
  40. {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_threads.py +0 -0
  41. {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_version.py +0 -0
  42. {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_web_risk_types.py +0 -0
  43. {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_webhooks.py +0 -0
  44. {replylayer-0.18.0 → replylayer-0.22.0}/tests/test_ws1_ws6.py +0 -0
  45. {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.18.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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "replylayer"
7
- version = "0.18.0"
7
+ version = "0.22.0"
8
8
  description = "Official Python SDK for ReplyLayer — email for AI agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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.18.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.18.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,