replylayer 0.21.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.21.0 → replylayer-0.22.0}/PKG-INFO +23 -1
  2. {replylayer-0.21.0 → replylayer-0.22.0}/README.md +22 -0
  3. {replylayer-0.21.0 → replylayer-0.22.0}/pyproject.toml +1 -1
  4. {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/__init__.py +17 -1
  5. {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/_http.py +1 -1
  6. {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/mailboxes.py +101 -0
  7. {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/types.py +104 -0
  8. {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_async.py +53 -0
  9. replylayer-0.22.0/tests/test_instruction_trust.py +37 -0
  10. {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_resources.py +45 -0
  11. {replylayer-0.21.0 → replylayer-0.22.0}/.gitignore +0 -0
  12. {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/__main__.py +0 -0
  13. {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/_client.py +0 -0
  14. {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/_pagination.py +0 -0
  15. {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/errors.py +0 -0
  16. {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/py.typed +0 -0
  17. {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/__init__.py +0 -0
  18. {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/account.py +0 -0
  19. {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/api_keys.py +0 -0
  20. {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/attachments.py +0 -0
  21. {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/domains.py +0 -0
  22. {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/drafts.py +0 -0
  23. {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/health.py +0 -0
  24. {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/inbound_blocklist.py +0 -0
  25. {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/legal_holds.py +0 -0
  26. {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/messages.py +0 -0
  27. {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/recipients.py +0 -0
  28. {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/suppressions.py +0 -0
  29. {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/threads.py +0 -0
  30. {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/webhooks.py +0 -0
  31. {replylayer-0.21.0 → replylayer-0.22.0}/tests/__init__.py +0 -0
  32. {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_attachments.py +0 -0
  33. {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_client.py +0 -0
  34. {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_domains.py +0 -0
  35. {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_drafts.py +0 -0
  36. {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_governed_email_effect.py +0 -0
  37. {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_hitl_review_types.py +0 -0
  38. {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_http.py +0 -0
  39. {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_messages_idempotency.py +0 -0
  40. {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_threads.py +0 -0
  41. {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_version.py +0 -0
  42. {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_web_risk_types.py +0 -0
  43. {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_webhooks.py +0 -0
  44. {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_ws1_ws6.py +0 -0
  45. {replylayer-0.21.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.21.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.21.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"
@@ -28,6 +28,7 @@ from .types import (
28
28
  ThreadStarResponse,
29
29
  # WS6-SDK — async optimistic-ack (0.16.0).
30
30
  AsyncSendAck,
31
+ WebhookEventType,
31
32
  WebhookSummary,
32
33
  WebhookDeliverySummary,
33
34
  WebhookDeliveryStatus,
@@ -49,6 +50,9 @@ from .types import (
49
50
  GetUploadAttachmentResponse,
50
51
  # Migration 036 — recipient allowlist types.
51
52
  RecipientPolicyMode,
53
+ # Single "agent sends" control — derived view types.
54
+ AgentSendPolicy,
55
+ AgentSendRestrictedBy,
52
56
  AllowlistEntry,
53
57
  ListAllowlistResponse,
54
58
  AddAllowlistResponse,
@@ -70,6 +74,11 @@ from .types import (
70
74
  EnableLinkScanningResponse,
71
75
  EnableLinkScanningDisclosure,
72
76
  AgentSafetyContext,
77
+ InstructionTrustBasis,
78
+ # R5 — verified-sender signal.
79
+ SenderAuthVerdict,
80
+ SenderAuthentication,
81
+ SenderAuthenticationCompact,
73
82
  # Governed Email-Effect Contract v1 (Track 2).
74
83
  EmailEffect,
75
84
  EffectStatus,
@@ -92,7 +101,7 @@ from .types import (
92
101
  ScannerPolicy,
93
102
  )
94
103
 
95
- __version__ = "0.21.0"
104
+ __version__ = "0.22.0"
96
105
 
97
106
  __all__ = [
98
107
  # Message delete response (0.20.0).
@@ -126,6 +135,7 @@ __all__ = [
126
135
  "EmailEffectRetryableError",
127
136
  "EmailEffect",
128
137
  "EffectStatus",
138
+ "WebhookEventType",
129
139
  "WebhookSummary",
130
140
  "WebhookDeliverySummary",
131
141
  "WebhookDeliveryStatus",
@@ -146,6 +156,8 @@ __all__ = [
146
156
  "ConsumedAttachmentResponse",
147
157
  "GetUploadAttachmentResponse",
148
158
  "RecipientPolicyMode",
159
+ "AgentSendPolicy",
160
+ "AgentSendRestrictedBy",
149
161
  "AllowlistEntry",
150
162
  "ListAllowlistResponse",
151
163
  "AddAllowlistResponse",
@@ -164,6 +176,10 @@ __all__ = [
164
176
  "EnableLinkScanningResponse",
165
177
  "EnableLinkScanningDisclosure",
166
178
  "AgentSafetyContext",
179
+ "InstructionTrustBasis",
180
+ "SenderAuthVerdict",
181
+ "SenderAuthentication",
182
+ "SenderAuthenticationCompact",
167
183
  "MessageReceivedSafetySignal",
168
184
  "MessageReceivedWebhookPayload",
169
185
  # PR 6 — HITL review queue.
@@ -9,7 +9,7 @@ import httpx
9
9
 
10
10
  from .errors import ReplyLayerError, error_from_response
11
11
 
12
- _VERSION = "0.21.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
 
@@ -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,
@@ -72,6 +72,8 @@ WebhookEventType = Literal[
72
72
  # matching endpoint commits the state transition. Subscribable via
73
73
  # POST/PATCH /v1/webhooks.enabled_events.
74
74
  "message.review.queued", "message.review.approved", "message.review.denied",
75
+ # Trusted-instruction-sources — customer grant/revoke of a trusted sender.
76
+ "trusted_source.granted", "trusted_source.revoked",
75
77
  "webhook.test",
76
78
  ]
77
79
  ApiKeyRole = Literal["admin", "agent"]
@@ -164,6 +166,21 @@ SubaddressMode = Literal["reply_to", "from", "none"]
164
166
  # still runs first (wins on overlap).
165
167
  RecipientPolicyMode = Literal["blocklist", "allowlist"]
166
168
 
169
+ # Single "agent sends" control — a DERIVED convenience view over
170
+ # recipient_policy_mode + agent_send_containment (no separate stored field).
171
+ # - "open": the agent may send to any new recipient (blocklist mailbox with
172
+ # containment off).
173
+ # - "restricted": the agent is gated (see restricted_by for why).
174
+ # See plans/agent-sends-policy-control-2026-06-26.md.
175
+ AgentSendPolicy = Literal["restricted", "open"]
176
+
177
+ # When agent_send_policy == "restricted", WHY:
178
+ # - "mailbox_allowlist": the whole mailbox is allowlist (humans too); opening
179
+ # the agent would also open human sends.
180
+ # - "agent_containment": blocklist mailbox + the R3 containment overlay.
181
+ # - None: agent_send_policy == "open".
182
+ AgentSendRestrictedBy = Literal["mailbox_allowlist", "agent_containment"] | None
183
+
167
184
  # Per-mailbox INBOUND sender firewall mode (migration 047). Symmetric
168
185
  # counterpart to RecipientPolicyMode for the inbound side.
169
186
  # - blocklist (default): inbound_sender_blocklists is the only ingest gate.
@@ -381,6 +398,12 @@ class CreateMailboxResponse(TypedDict):
381
398
  hitl_mode: HitlMode
382
399
  # Migration 085 — thread-scoped reply bypass flag; new mailboxes default True.
383
400
  allow_thread_replies: bool
401
+ # Migration 096 — R3 agent-send-containment opt-out; new mailboxes default True.
402
+ agent_send_containment: bool
403
+ # Single "agent sends" control — DERIVED from the two raw fields above.
404
+ agent_send_policy: AgentSendPolicy
405
+ # Why the agent is restricted (None when agent_send_policy == "open").
406
+ restricted_by: AgentSendRestrictedBy
384
407
 
385
408
 
386
409
  class Mailbox(TypedDict):
@@ -426,6 +449,18 @@ class Mailbox(TypedDict):
426
449
  # in allowlist mode permits a reply/follow-up to a visible inbound thread
427
450
  # participant without a standing grant.
428
451
  allow_thread_replies: bool
452
+ # Migration 096 — R3 agent-send-containment opt-out. When True (the default),
453
+ # an agent-key send on a native-blocklist mailbox is run through the
454
+ # allowlist + thread-participant gate. Strict no-op on native-allowlist.
455
+ agent_send_containment: bool
456
+ # Trusted-instruction-sources — per-mailbox master switch + strict-recipient
457
+ # policy. NotRequired for rollout tolerance (a pre-slice-6b server omits them).
458
+ instruction_trust_mode: NotRequired[Literal["disabled", "enabled"]]
459
+ instruction_trust_strict_recipient: NotRequired[bool]
460
+ # Single "agent sends" control — DERIVED from the two raw fields above.
461
+ agent_send_policy: AgentSendPolicy
462
+ # Why the agent is restricted (None when agent_send_policy == "open").
463
+ restricted_by: AgentSendRestrictedBy
429
464
  # PR 8.1 — per-detector redaction visibility. None = platform default
430
465
  # (all 13 detectors redact with `<TYPE>` placeholder). Populated value
431
466
  # is a partial map keyed by detector type; tier-gated Pro+ for any
@@ -467,6 +502,12 @@ class UpdateMailboxResponse(TypedDict):
467
502
  hitl_mode: HitlMode
468
503
  # Migration 085 — thread-scoped reply bypass flag.
469
504
  allow_thread_replies: bool
505
+ # Migration 096 — R3 agent-send-containment opt-out.
506
+ agent_send_containment: bool
507
+ # Single "agent sends" control — DERIVED from the two raw fields above.
508
+ agent_send_policy: AgentSendPolicy
509
+ # Why the agent is restricted (None when agent_send_policy == "open").
510
+ restricted_by: AgentSendRestrictedBy
470
511
  # PR 8.1 — per-detector redaction visibility (None = platform default).
471
512
  pii_redaction_config: PiiRedactionConfig | None
472
513
 
@@ -705,6 +746,8 @@ class MessageSummary(TypedDict, total=False):
705
746
  scan: NotRequired["ScanSummary | None"]
706
747
  # Phase 5 — inbound untrusted-body contract (present even on a clean scan); null/absent for outbound.
707
748
  agent_safety_context: NotRequired["AgentSafetyContext | None"]
749
+ # R5 — compact verified-sender signal {verdict}; None = not evaluated.
750
+ sender_authentication: NotRequired["SenderAuthenticationCompact | None"]
708
751
  # Migration 035 — may be `<REDACTED>` under mailbox pii_mode=redacted.
709
752
  subaddress_instance_id: Required[str | None]
710
753
  # Migration 047 — present (non-null) only on state='firewall_blocked'.
@@ -973,6 +1016,26 @@ class ScanSummary(TypedDict):
973
1016
  findings: list[ScanFinding]
974
1017
 
975
1018
 
1019
+ class InstructionTrustBasis(TypedDict):
1020
+ """Trusted-instruction-sources — thin BASIS metadata on a trusted-instruction
1021
+ message (plan §4). Present ONLY when the customer designated this verified
1022
+ sender as a trusted instruction source AND the reading credential is a
1023
+ ``role='agent'`` key with the per-key capability enabled. Relaxation is
1024
+ gated purely by operator server-side config (mailbox instruction-trust
1025
+ mode + per-key capability + a live trusted-source grant) plus the
1026
+ message's authenticity/scan state — there is no client-sent opt-in header
1027
+ or constructor flag. The behavioural contract is in
1028
+ ``AgentSafetyContext.guidance`` (REPLACED on a trusted message); this is
1029
+ metadata only. ``untrusted_content`` stays True."""
1030
+
1031
+ version: Literal["v1"]
1032
+ match: Literal["address"]
1033
+ # Verified sender's domain; None under pii_mode=redacted.
1034
+ verified_domain: str | None
1035
+ verdict: Literal["verified_aligned"]
1036
+ provenance: Literal["mailgun"]
1037
+
1038
+
976
1039
  class AgentSafetyContext(TypedDict):
977
1040
  """Top-level standing safety contract on inbound message reads + the
978
1041
  ``message.received`` webhook. Present (untrusted_content=True) on every
@@ -981,6 +1044,41 @@ class AgentSafetyContext(TypedDict):
981
1044
 
982
1045
  untrusted_content: bool
983
1046
  guidance: str
1047
+ # Present ONLY on a trusted-instruction message (gate passed + opted-in
1048
+ # credential). When present, ``guidance`` carries the trusted-message
1049
+ # instruction; this is thin metadata, not the contract.
1050
+ instruction_trust: NotRequired[InstructionTrustBasis]
1051
+
1052
+
1053
+ # R5 — verified-sender verdict. High-confidence DOMAIN authenticity, NOT
1054
+ # certainty: ``verified_aligned`` means the message genuinely originated from
1055
+ # its From domain (Mailgun DMARC pass + alignment); it does NOT prove the
1056
+ # person/account, lookalike-safety, or content-safety, and never relaxes the
1057
+ # inbound-untrusted contract. Tolerant reader: treat an unknown value as
1058
+ # unverified. ``None`` (the whole object) = not evaluated.
1059
+ SenderAuthVerdict = Literal[
1060
+ "verified_aligned",
1061
+ "authenticated_unaligned",
1062
+ "failed",
1063
+ "none",
1064
+ "error",
1065
+ ]
1066
+
1067
+
1068
+ class SenderAuthentication(TypedDict):
1069
+ """Full verified-sender signal on the detail surface. ``from_domain`` +
1070
+ ``signing_domain`` are nulled under ``pii_mode='redacted'``."""
1071
+
1072
+ verdict: SenderAuthVerdict
1073
+ from_domain: str | None
1074
+ signing_domain: str | None
1075
+ provenance: Literal["mailgun", "self_hosted_imap"]
1076
+
1077
+
1078
+ class SenderAuthenticationCompact(TypedDict):
1079
+ """Compact verified-sender signal on the list/wait surfaces."""
1080
+
1081
+ verdict: SenderAuthVerdict
984
1082
 
985
1083
 
986
1084
  ScanView = Literal["summary", "verbose"]
@@ -1017,6 +1115,9 @@ class GetMessageResponse(TypedDict, total=False):
1017
1115
  attachments: Required[list[MessageAttachment]]
1018
1116
  scan: Required[ScanSummary | None]
1019
1117
  agent_safety_context: NotRequired["AgentSafetyContext | None"]
1118
+ # R5 — full verified-sender signal; domains nulled under pii_mode=redacted;
1119
+ # None = not evaluated.
1120
+ sender_authentication: NotRequired["SenderAuthentication | None"]
1020
1121
  # Governed Email-Effect Contract v1 (Track 2). Outbound-only derived VIEW;
1021
1122
  # OMITTED (key absent) on inbound rows (which carry agent_safety_context) and
1022
1123
  # on never-sent / pre-dispatch-transient outbound rows.
@@ -1650,6 +1751,9 @@ class ApiKeySummary(TypedDict):
1650
1751
  mailbox_ids: list[str]
1651
1752
  created_at: str
1652
1753
  last_used_at: str | None
1754
+ # Trusted-instruction-sources — the per-key opt-in capability (slice 6b).
1755
+ # Only an agent key can hold it. NotRequired for rollout tolerance.
1756
+ instruction_trust_enabled: NotRequired[bool]
1653
1757
 
1654
1758
 
1655
1759
  class ListApiKeysResponse(TypedDict):
@@ -233,6 +233,59 @@ async def test_async_mailboxes_create_with_self_hosted_imap_folder():
233
233
  await rl.aclose()
234
234
 
235
235
 
236
+ # ── Single "agent sends" control — set_agent_send_policy body shape (async) ──
237
+ # plans/agent-sends-policy-control-2026-06-26.md.
238
+
239
+
240
+ @respx.mock
241
+ @pytest.mark.asyncio
242
+ async def test_async_set_agent_send_policy_sends_policy_alone_without_confirm():
243
+ """Async helper sends ONLY agent_send_policy when no confirm flag is passed."""
244
+ route = respx.patch(f"{BASE}/v1/mailboxes/m1").mock(
245
+ return_value=httpx.Response(200, json={"id": "m1", "name": "support"}),
246
+ )
247
+ rl = async_sdk()
248
+ await rl.mailboxes.set_agent_send_policy("m1", "restricted")
249
+ payload = json.loads(route.calls[0].request.content)
250
+ assert payload == {"agent_send_policy": "restricted"}
251
+ assert "confirm_open_human_sends" not in payload
252
+ await rl.aclose()
253
+
254
+
255
+ @respx.mock
256
+ @pytest.mark.asyncio
257
+ async def test_async_set_agent_send_policy_includes_confirm_only_when_true():
258
+ """Async helper forwards confirm_open_human_sends=True on the open-allowlist
259
+ confirmation path."""
260
+ route = respx.patch(f"{BASE}/v1/mailboxes/m1").mock(
261
+ return_value=httpx.Response(200, json={"id": "m1", "name": "support"}),
262
+ )
263
+ rl = async_sdk()
264
+ await rl.mailboxes.set_agent_send_policy(
265
+ "m1", "open", confirm_open_human_sends=True,
266
+ )
267
+ payload = json.loads(route.calls[0].request.content)
268
+ assert payload == {"agent_send_policy": "open", "confirm_open_human_sends": True}
269
+ await rl.aclose()
270
+
271
+
272
+ @respx.mock
273
+ @pytest.mark.asyncio
274
+ async def test_async_set_agent_send_policy_omits_confirm_when_false():
275
+ """A falsy confirm_open_human_sends does NOT add the flag to the async body."""
276
+ route = respx.patch(f"{BASE}/v1/mailboxes/m1").mock(
277
+ return_value=httpx.Response(200, json={"id": "m1", "name": "support"}),
278
+ )
279
+ rl = async_sdk()
280
+ await rl.mailboxes.set_agent_send_policy(
281
+ "m1", "restricted", confirm_open_human_sends=False,
282
+ )
283
+ payload = json.loads(route.calls[0].request.content)
284
+ assert payload == {"agent_send_policy": "restricted"}
285
+ assert "confirm_open_human_sends" not in payload
286
+ await rl.aclose()
287
+
288
+
236
289
  @respx.mock
237
290
  @pytest.mark.asyncio
238
291
  async def test_async_mailboxes_set_attachment_access_posts_legacy_shape_without_unset_keys():
@@ -0,0 +1,37 @@
1
+ """
2
+ Python SDK — trusted-instruction-sources READ MIRROR.
3
+
4
+ Design change (option 2): relaxation is gated purely by operator server-side
5
+ config (mailbox instruction_trust_mode + per-key instruction_trust_enabled +
6
+ a live trusted-source grant) plus the message's authenticity/scan state.
7
+ There is no client-sent opt-in header or constructor flag anymore — the SDK
8
+ only reads and types the ``instruction_trust`` basis the server may attach to
9
+ ``agent_safety_context`` on a response.
10
+ """
11
+
12
+ from replylayer import InstructionTrustBasis
13
+
14
+
15
+ def test_instruction_trust_basis_type_is_exported():
16
+ # The read-mirror type is part of the public surface (parity with the TS SDK).
17
+ basis: InstructionTrustBasis = {
18
+ "version": "v1",
19
+ "match": "address",
20
+ "verified_domain": "partner.com",
21
+ "verdict": "verified_aligned",
22
+ "provenance": "mailgun",
23
+ }
24
+ assert basis["verified_domain"] == "partner.com"
25
+
26
+
27
+ def test_webhook_event_types_include_trusted_source():
28
+ # P2 drift pin — the SDK WebhookEventType mirror must carry the slice-4b
29
+ # trusted-source events (parity with shared/server), else typed callers
30
+ # cannot subscribe without casts.
31
+ import typing
32
+
33
+ from replylayer import WebhookEventType
34
+
35
+ members = set(typing.get_args(WebhookEventType))
36
+ assert "trusted_source.granted" in members
37
+ assert "trusted_source.revoked" in members
@@ -550,6 +550,51 @@ def test_set_recipient_policy_passes_scanner_policy_when_provided():
550
550
  }
551
551
 
552
552
 
553
+ # ── Single "agent sends" control — set_agent_send_policy body shape (sync) ──
554
+ # plans/agent-sends-policy-control-2026-06-26.md.
555
+
556
+
557
+ @respx.mock
558
+ def test_set_agent_send_policy_sends_policy_alone_without_confirm():
559
+ """The convenience helper sends ONLY agent_send_policy when no confirm flag
560
+ is passed — confirm_open_human_sends must NOT ride along (default False)."""
561
+ route = respx.patch(f"{BASE}/v1/mailboxes/m1").mock(
562
+ return_value=httpx.Response(200, json={"id": "m1", "name": "support"}),
563
+ )
564
+ sdk().mailboxes.set_agent_send_policy("m1", "restricted")
565
+ payload = json.loads(route.calls[0].request.content)
566
+ assert payload == {"agent_send_policy": "restricted"}
567
+ assert "confirm_open_human_sends" not in payload
568
+
569
+
570
+ @respx.mock
571
+ def test_set_agent_send_policy_includes_confirm_only_when_true():
572
+ """confirm_open_human_sends=True is forwarded; the consent flag rides along
573
+ only on the open-allowlist confirmation path."""
574
+ route = respx.patch(f"{BASE}/v1/mailboxes/m1").mock(
575
+ return_value=httpx.Response(200, json={"id": "m1", "name": "support"}),
576
+ )
577
+ sdk().mailboxes.set_agent_send_policy(
578
+ "m1", "open", confirm_open_human_sends=True,
579
+ )
580
+ payload = json.loads(route.calls[0].request.content)
581
+ assert payload == {"agent_send_policy": "open", "confirm_open_human_sends": True}
582
+
583
+
584
+ @respx.mock
585
+ def test_set_agent_send_policy_omits_confirm_when_false():
586
+ """A falsy confirm_open_human_sends does NOT add the flag to the body."""
587
+ route = respx.patch(f"{BASE}/v1/mailboxes/m1").mock(
588
+ return_value=httpx.Response(200, json={"id": "m1", "name": "support"}),
589
+ )
590
+ sdk().mailboxes.set_agent_send_policy(
591
+ "m1", "restricted", confirm_open_human_sends=False,
592
+ )
593
+ payload = json.loads(route.calls[0].request.content)
594
+ assert payload == {"agent_send_policy": "restricted"}
595
+ assert "confirm_open_human_sends" not in payload
596
+
597
+
553
598
  # ── PR 8.1 — pii_redaction_config request forwarding (round-5 audit fix #3) ──
554
599
 
555
600
 
File without changes
File without changes