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.
- {replylayer-0.21.0 → replylayer-0.22.0}/PKG-INFO +23 -1
- {replylayer-0.21.0 → replylayer-0.22.0}/README.md +22 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/pyproject.toml +1 -1
- {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/__init__.py +17 -1
- {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/_http.py +1 -1
- {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/mailboxes.py +101 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/types.py +104 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_async.py +53 -0
- replylayer-0.22.0/tests/test_instruction_trust.py +37 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_resources.py +45 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/.gitignore +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/__main__.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/_client.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/_pagination.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/errors.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/py.typed +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/__init__.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/account.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/api_keys.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/attachments.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/domains.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/drafts.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/health.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/inbound_blocklist.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/legal_holds.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/messages.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/recipients.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/suppressions.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/threads.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/replylayer/resources/webhooks.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/tests/__init__.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_attachments.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_client.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_domains.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_drafts.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_governed_email_effect.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_hitl_review_types.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_http.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_messages_idempotency.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_threads.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_version.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_web_risk_types.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_webhooks.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.0}/tests/test_ws1_ws6.py +0 -0
- {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.
|
|
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.
|
|
@@ -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.
|
|
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.
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|