replylayer 0.21.0__tar.gz → 0.22.1__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 (46) hide show
  1. replylayer-0.21.0/README.md → replylayer-0.22.1/PKG-INFO +91 -12
  2. replylayer-0.21.0/PKG-INFO → replylayer-0.22.1/README.md +71 -29
  3. {replylayer-0.21.0 → replylayer-0.22.1}/pyproject.toml +6 -1
  4. {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/__init__.py +17 -1
  5. {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/_http.py +1 -1
  6. {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/errors.py +3 -4
  7. {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/mailboxes.py +101 -0
  8. {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/types.py +119 -0
  9. {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_async.py +53 -0
  10. {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_drafts.py +5 -5
  11. replylayer-0.22.1/tests/test_instruction_trust.py +37 -0
  12. replylayer-0.22.1/tests/test_readme_resource_parity.py +80 -0
  13. {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_resources.py +75 -0
  14. {replylayer-0.21.0 → replylayer-0.22.1}/.gitignore +0 -0
  15. {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/__main__.py +0 -0
  16. {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/_client.py +0 -0
  17. {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/_pagination.py +0 -0
  18. {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/py.typed +0 -0
  19. {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/__init__.py +0 -0
  20. {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/account.py +0 -0
  21. {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/api_keys.py +0 -0
  22. {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/attachments.py +0 -0
  23. {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/domains.py +0 -0
  24. {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/drafts.py +0 -0
  25. {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/health.py +0 -0
  26. {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/inbound_blocklist.py +0 -0
  27. {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/legal_holds.py +0 -0
  28. {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/messages.py +0 -0
  29. {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/recipients.py +0 -0
  30. {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/suppressions.py +0 -0
  31. {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/threads.py +0 -0
  32. {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/webhooks.py +0 -0
  33. {replylayer-0.21.0 → replylayer-0.22.1}/tests/__init__.py +0 -0
  34. {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_attachments.py +0 -0
  35. {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_client.py +0 -0
  36. {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_domains.py +0 -0
  37. {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_governed_email_effect.py +0 -0
  38. {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_hitl_review_types.py +0 -0
  39. {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_http.py +0 -0
  40. {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_messages_idempotency.py +0 -0
  41. {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_threads.py +0 -0
  42. {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_version.py +0 -0
  43. {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_web_risk_types.py +0 -0
  44. {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_webhooks.py +0 -0
  45. {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_ws1_ws6.py +0 -0
  46. {replylayer-0.21.0 → replylayer-0.22.1}/uv.lock +0 -0
@@ -1,3 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: replylayer
3
+ Version: 0.22.1
4
+ Summary: Official Python SDK for ReplyLayer — email for AI agents
5
+ Project-URL: Homepage, https://replylayer.ai
6
+ Project-URL: Repository, https://github.com/replylayer/rly
7
+ Project-URL: Issues, https://github.com/replylayer/rly/issues
8
+ License-Expression: MIT
9
+ Keywords: agent,ai,email,mailbox,replylayer,sdk,webhook
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: httpx>=0.27
12
+ Requires-Dist: typing-extensions>=4.0
13
+ Provides-Extra: cli
14
+ Requires-Dist: rly>=0.6.3; extra == 'cli'
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
17
+ Requires-Dist: pytest>=8.0; extra == 'dev'
18
+ Requires-Dist: respx>=0.21; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
1
21
  # replylayer
2
22
 
3
23
  Official Python SDK for [ReplyLayer](https://replylayer.ai) — secure email for AI agents.
@@ -80,7 +100,8 @@ contract — read it before relying on retries:
80
100
  - **`5xx` is retried only on non-mutating (`GET`) requests.** A `5xx` on a
81
101
  `POST` / `PATCH` / `DELETE` is **not** retried — the request may have executed,
82
102
  so a retry risks a double-send (or, for `DELETE`, retrying a lost-but-applied
83
- delete into a confusing `404`).
103
+ delete into a confusing `404`). To retry a `send` / `reply` safely, pass an
104
+ idempotency key — see [Idempotent sends](#idempotent-sends).
84
105
  - **Multipart uploads are never retried** (a retry would re-send the body).
85
106
  - **Long `Retry-After` values block up to `max_retry_after_seconds`** (default
86
107
  ~67 minutes, sized to ride out hour-bucket rate limits for batch jobs). When a
@@ -95,21 +116,57 @@ contract — read it before relying on retries:
95
116
  may be a coroutine (it's awaited); a raising callback is swallowed so it can't
96
117
  break the retry.
97
118
 
119
+ ## Idempotent sends
120
+
121
+ Because a `5xx` on a send is **not** auto-retried (a blind retry risks a second
122
+ delivery + a second charge), the SDK gives you a way to retry it yourself
123
+ *safely*. `messages.send`, `messages.reply`, and scheduled sends via
124
+ `drafts.create` (with `send_at`) accept an `idempotency_key`: a network-retried
125
+ request carrying the same key produces **at most one** message and one charge —
126
+ the server replays the original outcome and returns the **same `message_id`**
127
+ instead of sending again.
128
+
129
+ ```python
130
+ import uuid
131
+
132
+ key = str(uuid.uuid4()) # stable per send intent — persist it with the job
133
+
134
+ # First call sends; a same-key retry replays the first result (no second send).
135
+ sent = rl.messages.send(
136
+ from_mailbox="support",
137
+ to="user@example.com",
138
+ subject="Hi",
139
+ body="Hello",
140
+ idempotency_key=key,
141
+ )
142
+ print(sent["message_id"]) # a same-key retry returns this SAME id
143
+ ```
144
+
145
+ The key travels as the `Idempotency-Key` request header and is permanent (no
146
+ expiry). A non-throwing probe, `rl.messages.get_idempotency_replay(key)`, reports
147
+ whether a key already produced a result, is still in flight, or is a miss — call
148
+ it before a retry whose local inputs (a staged attachment, the original message)
149
+ may no longer be available. The async client exposes the same methods.
150
+
98
151
  ## Resources
99
152
 
100
153
  | Resource | Methods |
101
154
  |----------|---------|
102
- | `rl.mailboxes` | `create`, `list`, `delete`, `update`, `set_recipient_policy` |
155
+ | `rl.domains` | `create`, `list`, `get`, `verify`, `update_self_hosted_config`, `delete`, `set_default`, `recheck` |
156
+ | `rl.mailboxes` | `create`, `list`, `get_mailbox`, `delete`, `update`, `set_recipient_policy`, `set_thread_replies`, `set_agent_send_containment`, `set_agent_send_policy`, `set_attachment_access`, `set_sender_policy` |
103
157
  | `rl.mailboxes.allowlist` | `list`, `add`, `add_bulk`, `delete`, `list_blocked_attempts` |
104
- | `rl.messages` | `send`, `list`, `get`, `reply`, `wait`, `release`, `block`, `set_starred` |
158
+ | `rl.mailboxes.inbound_allowlist` | `list`, `add`, `add_bulk`, `delete`, `list_blocked_attempts` |
159
+ | `rl.messages` | `send`, `list`, `get`, `reply`, `get_idempotency_replay`, `wait`, `release`, `block`, `report`, `delete`, `firewall_release`, `mark_read`, `set_starred`, `approve_review`, `deny_review` |
105
160
  | `rl.drafts` | `create`, `get`, `list`, `update`, `send`, `delete` |
106
- | `rl.threads` | `list`, `get`, `set_starred` |
161
+ | `rl.threads` | `list`, `get`, `mark_read`, `set_starred` |
107
162
  | `rl.attachments` | `get_download_url`, `get_preview`, `upload`, `get_upload`, `delete_upload` |
108
163
  | `rl.webhooks` | `create`, `list`, `get`, `update`, `delete`, `rotate_secret`, `test`, `list_deliveries`, `retry_delivery` |
109
164
  | `rl.recipients` | `create`, `list`, `delete`, `resend` |
110
- | `rl.suppressions` | `list`, `delete` |
165
+ | `rl.suppressions` | `list`, `add`, `add_bulk`, `delete` |
166
+ | `rl.inbound_blocklist` | `list`, `add`, `add_bulk`, `delete` |
111
167
  | `rl.api_keys` | `create`, `list`, `revoke`, `rotate`* |
112
- | `rl.account` | `get_usage`, `get_quota`, `get_link_scanning_status`, `enable_link_scanning` |
168
+ | `rl.account` | `get_usage`, `get_quota`, `get_link_scanning_status`, `enable_link_scanning`, `export` |
169
+ | `rl.legal_holds` | `apply`, `release`, `list`, `get` |
113
170
  | `rl.health` | `check` |
114
171
 
115
172
  *`api_keys.rotate()` revokes the calling API key and returns a new one. After calling it, this SDK instance's key is invalidated — create a new `ReplyLayer` instance with the returned key.
@@ -132,7 +189,7 @@ if draft["worst_decision"] == "allow":
132
189
 
133
190
  The send/reply/draft-send response carries two additive, nullable keys that explain a held send inline (no second `messages.get` call). `result["scan"]` is the vendor-neutral scanner verdict (`ScanSummary`); `result["hold_context"]` (`{"trigger_source", "summary_reasons"}` or `None`) is the policy/HITL reason, non-null only when the delivery `status` diverges from `scan["verdict"]` because of a policy/HITL hold — a clean scan held for review by your mailbox policy, or a scanner review-flag held as quarantine on a plan without the review queue (`trigger_source`: `mailbox_policy` | `scanner` | `both`).
134
191
 
135
- By default `drafts.send()`, `messages.send()`, and `messages.reply()` return only once the scanner verdict is known, with `scan` and `hold_context` inline. Pass `async_dispatch=True` to `drafts.send()` to send the `Prefer: respond-async` hint. **The hint is advisory** — the server returns a `202 AsyncSendAck` only when `OUTBOUND_ASYNC_DISPATCH_ENABLED` is on; otherwise it ignores the hint and returns a normal `SendMessageResponse`. **Always branch on the result**: `result["status"] == "queued_for_dispatch"` ⇒ `AsyncSendAck`, otherwise `SendMessageResponse`. Poll `messages.get(message_id)` (or handle the lifecycle webhook) until `state` is terminal. Attachment-bearing drafts fail closed on the async path (`400 ATTACHMENTS_REQUIRE_SYNC_SEND`). (`messages.wait()` is a mailbox long-poll for new *inbound* mail, not a way to observe a specific message by ID.)
192
+ By default `drafts.send()`, `messages.send()`, and `messages.reply()` return only once the scanner verdict is known, with `scan` and `hold_context` inline. Pass `async_dispatch=True` to `drafts.send()` to send the `Prefer: respond-async` hint. **The hint is advisory — the server decides.** When async dispatch is available the server returns `202` with `status="queued_for_dispatch"` (`AsyncSendAck`); otherwise it ignores the hint and returns a normal `200` `SendMessageResponse`. **Always branch on the result**: `result["status"] == "queued_for_dispatch"` ⇒ `AsyncSendAck`, otherwise `SendMessageResponse`. Poll `messages.get(message_id)` (or handle the lifecycle webhook) until `state` is terminal. Attachment-bearing drafts fail closed on the async path (`400 ATTACHMENTS_REQUIRE_SYNC_SEND`). (`messages.wait()` is a mailbox long-poll for new *inbound* mail, not a way to observe a specific message by ID.)
136
193
 
137
194
  The send endpoint raises `ReplyLayerError` with distinct `.code` values on 409:
138
195
  - `DRAFT_REJECTED_BY_RESCAN` — send-time scan flipped the verdict to `block`/`quarantine`. The draft stays in `draft` state; edit the body and retry. `err.details` carries `scan`, `releasable` (`True` for a `quarantine` hold the customer can release via `POST /v1/drafts/:id/release-and-send`, `False` for a terminal `block`), and, when a policy/HITL decision drove the hold, `hold_context`.
@@ -236,7 +293,7 @@ rl.mailboxes.update(
236
293
 
237
294
  ### Advanced PII config (Pro+)
238
295
 
239
- PR 8 added `pii_redaction_config` for **per-detector** control over redaction (e.g. "leave email visible, redact everything else") and **operator-level** rendering (`partial_mask` for credit cards, `hash_replace` for emails you want to dedupe without exposing). Pro+ feature; only meaningful when `pii_mode="redacted"`.
296
+ `pii_redaction_config` gives **per-detector** control over redaction (e.g. "leave email visible, redact everything else") and **operator-level** rendering (`partial_mask` for credit cards, `hash_replace` for emails you want to dedupe without exposing). Pro+ feature; only meaningful when `pii_mode="redacted"`.
240
297
 
241
298
  ```python
242
299
  # Per-detector toggle: show emails to the agent, keep everything else redacted.
@@ -358,7 +415,7 @@ A send/reply/draft-send to a recipient on your do-not-contact (suppression) list
358
415
 
359
416
  Allowlist mutations are admin-only — granting send permission to an LLM defeats the containment boundary. Agents *can* `list` (so they can see what they're allowed to email) but not `add`/`add_bulk`/`delete`. Three new webhook events: `recipient_allowlist.added`, `recipient_allowlist.removed`, `mailbox.recipient_policy_changed`.
360
417
 
361
- ### Domain entries (sprint 039)
418
+ ### Domain entries
362
419
 
363
420
  Entries can be either an exact email (`alice@corp.com`) or a bare-domain pattern (`@corp.com`) that matches every address at that domain. Exact-domain only — `@corp.com` matches `*@corp.com` but NOT `eve@sub.corp.com`.
364
421
 
@@ -383,7 +440,7 @@ Responses expose `pattern_type: "email" | "domain"` on every add/list/delete/bul
383
440
 
384
441
  Blocklist precedence still holds: a domain-block beats an exact-allow at the same domain. Malformed patterns (`@`, `@.com`, `@foo`, `@corp-.com`, non-ASCII) raise `ReplyLayerError` with `.code == "INVALID_EMAIL"` (message: `"Invalid email or domain pattern"`).
385
442
 
386
- ### Blocked attempts (migration 038)
443
+ ### Blocked attempts
387
444
 
388
445
  Every send the allowlist gate rejects writes an append-only audit row and emits a deduped `recipient_allowlist.blocked_attempt` webhook. Review the log to see what your agent tried to email and one-click add legitimate recipients.
389
446
 
@@ -426,6 +483,28 @@ if not status["active"] and status["privacy_ok"]:
426
483
 
427
484
  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
485
 
486
+ ## Trusted instruction sources
487
+
488
+ 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.
489
+
490
+ 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:
491
+
492
+ ```python
493
+ result = rl.messages.wait(mailbox["id"])
494
+ msg = result["message"]
495
+ ctx = msg.get("agent_safety_context") if msg else None
496
+ if ctx and ctx.get("instruction_trust"):
497
+ # Gate passed: guidance was REPLACED with trusted-instruction wording (the
498
+ # agent may act on this verified sender's own explicit request in this
499
+ # message). untrusted_content is still True — this is metadata, not a
500
+ # content-safety judgment.
501
+ print(ctx["guidance"])
502
+ print(ctx["instruction_trust"])
503
+ # {"version": "v1", "match": "address", "verified_domain": ..., "verdict": "verified_aligned", "provenance": "mailgun"}
504
+ ```
505
+
506
+ 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.
507
+
429
508
  ## Mailbox identifiers
430
509
 
431
510
  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.
@@ -491,9 +570,9 @@ import json
491
570
  payload = json.loads(request.body)
492
571
  # payload["event"] is the discriminator — NOT payload["type"]
493
572
  if payload["event"] == "message.received":
494
- # handle inbound message
573
+ print("handle inbound message")
495
574
  elif payload["event"] == "message.dispatch_failed":
496
- # handle failed outbound send
575
+ print("handle failed outbound send")
497
576
  ```
498
577
 
499
578
  ## Context managers
@@ -1,20 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: replylayer
3
- Version: 0.21.0
4
- Summary: Official Python SDK for ReplyLayer — email for AI agents
5
- License-Expression: MIT
6
- Keywords: agent,ai,email,mailbox,replylayer,sdk,webhook
7
- Requires-Python: >=3.10
8
- Requires-Dist: httpx>=0.27
9
- Requires-Dist: typing-extensions>=4.0
10
- Provides-Extra: cli
11
- Requires-Dist: rly>=0.6.3; extra == 'cli'
12
- Provides-Extra: dev
13
- Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
14
- Requires-Dist: pytest>=8.0; extra == 'dev'
15
- Requires-Dist: respx>=0.21; extra == 'dev'
16
- Description-Content-Type: text/markdown
17
-
18
1
  # replylayer
19
2
 
20
3
  Official Python SDK for [ReplyLayer](https://replylayer.ai) — secure email for AI agents.
@@ -97,7 +80,8 @@ contract — read it before relying on retries:
97
80
  - **`5xx` is retried only on non-mutating (`GET`) requests.** A `5xx` on a
98
81
  `POST` / `PATCH` / `DELETE` is **not** retried — the request may have executed,
99
82
  so a retry risks a double-send (or, for `DELETE`, retrying a lost-but-applied
100
- delete into a confusing `404`).
83
+ delete into a confusing `404`). To retry a `send` / `reply` safely, pass an
84
+ idempotency key — see [Idempotent sends](#idempotent-sends).
101
85
  - **Multipart uploads are never retried** (a retry would re-send the body).
102
86
  - **Long `Retry-After` values block up to `max_retry_after_seconds`** (default
103
87
  ~67 minutes, sized to ride out hour-bucket rate limits for batch jobs). When a
@@ -112,21 +96,57 @@ contract — read it before relying on retries:
112
96
  may be a coroutine (it's awaited); a raising callback is swallowed so it can't
113
97
  break the retry.
114
98
 
99
+ ## Idempotent sends
100
+
101
+ Because a `5xx` on a send is **not** auto-retried (a blind retry risks a second
102
+ delivery + a second charge), the SDK gives you a way to retry it yourself
103
+ *safely*. `messages.send`, `messages.reply`, and scheduled sends via
104
+ `drafts.create` (with `send_at`) accept an `idempotency_key`: a network-retried
105
+ request carrying the same key produces **at most one** message and one charge —
106
+ the server replays the original outcome and returns the **same `message_id`**
107
+ instead of sending again.
108
+
109
+ ```python
110
+ import uuid
111
+
112
+ key = str(uuid.uuid4()) # stable per send intent — persist it with the job
113
+
114
+ # First call sends; a same-key retry replays the first result (no second send).
115
+ sent = rl.messages.send(
116
+ from_mailbox="support",
117
+ to="user@example.com",
118
+ subject="Hi",
119
+ body="Hello",
120
+ idempotency_key=key,
121
+ )
122
+ print(sent["message_id"]) # a same-key retry returns this SAME id
123
+ ```
124
+
125
+ The key travels as the `Idempotency-Key` request header and is permanent (no
126
+ expiry). A non-throwing probe, `rl.messages.get_idempotency_replay(key)`, reports
127
+ whether a key already produced a result, is still in flight, or is a miss — call
128
+ it before a retry whose local inputs (a staged attachment, the original message)
129
+ may no longer be available. The async client exposes the same methods.
130
+
115
131
  ## Resources
116
132
 
117
133
  | Resource | Methods |
118
134
  |----------|---------|
119
- | `rl.mailboxes` | `create`, `list`, `delete`, `update`, `set_recipient_policy` |
135
+ | `rl.domains` | `create`, `list`, `get`, `verify`, `update_self_hosted_config`, `delete`, `set_default`, `recheck` |
136
+ | `rl.mailboxes` | `create`, `list`, `get_mailbox`, `delete`, `update`, `set_recipient_policy`, `set_thread_replies`, `set_agent_send_containment`, `set_agent_send_policy`, `set_attachment_access`, `set_sender_policy` |
120
137
  | `rl.mailboxes.allowlist` | `list`, `add`, `add_bulk`, `delete`, `list_blocked_attempts` |
121
- | `rl.messages` | `send`, `list`, `get`, `reply`, `wait`, `release`, `block`, `set_starred` |
138
+ | `rl.mailboxes.inbound_allowlist` | `list`, `add`, `add_bulk`, `delete`, `list_blocked_attempts` |
139
+ | `rl.messages` | `send`, `list`, `get`, `reply`, `get_idempotency_replay`, `wait`, `release`, `block`, `report`, `delete`, `firewall_release`, `mark_read`, `set_starred`, `approve_review`, `deny_review` |
122
140
  | `rl.drafts` | `create`, `get`, `list`, `update`, `send`, `delete` |
123
- | `rl.threads` | `list`, `get`, `set_starred` |
141
+ | `rl.threads` | `list`, `get`, `mark_read`, `set_starred` |
124
142
  | `rl.attachments` | `get_download_url`, `get_preview`, `upload`, `get_upload`, `delete_upload` |
125
143
  | `rl.webhooks` | `create`, `list`, `get`, `update`, `delete`, `rotate_secret`, `test`, `list_deliveries`, `retry_delivery` |
126
144
  | `rl.recipients` | `create`, `list`, `delete`, `resend` |
127
- | `rl.suppressions` | `list`, `delete` |
145
+ | `rl.suppressions` | `list`, `add`, `add_bulk`, `delete` |
146
+ | `rl.inbound_blocklist` | `list`, `add`, `add_bulk`, `delete` |
128
147
  | `rl.api_keys` | `create`, `list`, `revoke`, `rotate`* |
129
- | `rl.account` | `get_usage`, `get_quota`, `get_link_scanning_status`, `enable_link_scanning` |
148
+ | `rl.account` | `get_usage`, `get_quota`, `get_link_scanning_status`, `enable_link_scanning`, `export` |
149
+ | `rl.legal_holds` | `apply`, `release`, `list`, `get` |
130
150
  | `rl.health` | `check` |
131
151
 
132
152
  *`api_keys.rotate()` revokes the calling API key and returns a new one. After calling it, this SDK instance's key is invalidated — create a new `ReplyLayer` instance with the returned key.
@@ -149,7 +169,7 @@ if draft["worst_decision"] == "allow":
149
169
 
150
170
  The send/reply/draft-send response carries two additive, nullable keys that explain a held send inline (no second `messages.get` call). `result["scan"]` is the vendor-neutral scanner verdict (`ScanSummary`); `result["hold_context"]` (`{"trigger_source", "summary_reasons"}` or `None`) is the policy/HITL reason, non-null only when the delivery `status` diverges from `scan["verdict"]` because of a policy/HITL hold — a clean scan held for review by your mailbox policy, or a scanner review-flag held as quarantine on a plan without the review queue (`trigger_source`: `mailbox_policy` | `scanner` | `both`).
151
171
 
152
- By default `drafts.send()`, `messages.send()`, and `messages.reply()` return only once the scanner verdict is known, with `scan` and `hold_context` inline. Pass `async_dispatch=True` to `drafts.send()` to send the `Prefer: respond-async` hint. **The hint is advisory** — the server returns a `202 AsyncSendAck` only when `OUTBOUND_ASYNC_DISPATCH_ENABLED` is on; otherwise it ignores the hint and returns a normal `SendMessageResponse`. **Always branch on the result**: `result["status"] == "queued_for_dispatch"` ⇒ `AsyncSendAck`, otherwise `SendMessageResponse`. Poll `messages.get(message_id)` (or handle the lifecycle webhook) until `state` is terminal. Attachment-bearing drafts fail closed on the async path (`400 ATTACHMENTS_REQUIRE_SYNC_SEND`). (`messages.wait()` is a mailbox long-poll for new *inbound* mail, not a way to observe a specific message by ID.)
172
+ By default `drafts.send()`, `messages.send()`, and `messages.reply()` return only once the scanner verdict is known, with `scan` and `hold_context` inline. Pass `async_dispatch=True` to `drafts.send()` to send the `Prefer: respond-async` hint. **The hint is advisory — the server decides.** When async dispatch is available the server returns `202` with `status="queued_for_dispatch"` (`AsyncSendAck`); otherwise it ignores the hint and returns a normal `200` `SendMessageResponse`. **Always branch on the result**: `result["status"] == "queued_for_dispatch"` ⇒ `AsyncSendAck`, otherwise `SendMessageResponse`. Poll `messages.get(message_id)` (or handle the lifecycle webhook) until `state` is terminal. Attachment-bearing drafts fail closed on the async path (`400 ATTACHMENTS_REQUIRE_SYNC_SEND`). (`messages.wait()` is a mailbox long-poll for new *inbound* mail, not a way to observe a specific message by ID.)
153
173
 
154
174
  The send endpoint raises `ReplyLayerError` with distinct `.code` values on 409:
155
175
  - `DRAFT_REJECTED_BY_RESCAN` — send-time scan flipped the verdict to `block`/`quarantine`. The draft stays in `draft` state; edit the body and retry. `err.details` carries `scan`, `releasable` (`True` for a `quarantine` hold the customer can release via `POST /v1/drafts/:id/release-and-send`, `False` for a terminal `block`), and, when a policy/HITL decision drove the hold, `hold_context`.
@@ -253,7 +273,7 @@ rl.mailboxes.update(
253
273
 
254
274
  ### Advanced PII config (Pro+)
255
275
 
256
- PR 8 added `pii_redaction_config` for **per-detector** control over redaction (e.g. "leave email visible, redact everything else") and **operator-level** rendering (`partial_mask` for credit cards, `hash_replace` for emails you want to dedupe without exposing). Pro+ feature; only meaningful when `pii_mode="redacted"`.
276
+ `pii_redaction_config` gives **per-detector** control over redaction (e.g. "leave email visible, redact everything else") and **operator-level** rendering (`partial_mask` for credit cards, `hash_replace` for emails you want to dedupe without exposing). Pro+ feature; only meaningful when `pii_mode="redacted"`.
257
277
 
258
278
  ```python
259
279
  # Per-detector toggle: show emails to the agent, keep everything else redacted.
@@ -375,7 +395,7 @@ A send/reply/draft-send to a recipient on your do-not-contact (suppression) list
375
395
 
376
396
  Allowlist mutations are admin-only — granting send permission to an LLM defeats the containment boundary. Agents *can* `list` (so they can see what they're allowed to email) but not `add`/`add_bulk`/`delete`. Three new webhook events: `recipient_allowlist.added`, `recipient_allowlist.removed`, `mailbox.recipient_policy_changed`.
377
397
 
378
- ### Domain entries (sprint 039)
398
+ ### Domain entries
379
399
 
380
400
  Entries can be either an exact email (`alice@corp.com`) or a bare-domain pattern (`@corp.com`) that matches every address at that domain. Exact-domain only — `@corp.com` matches `*@corp.com` but NOT `eve@sub.corp.com`.
381
401
 
@@ -400,7 +420,7 @@ Responses expose `pattern_type: "email" | "domain"` on every add/list/delete/bul
400
420
 
401
421
  Blocklist precedence still holds: a domain-block beats an exact-allow at the same domain. Malformed patterns (`@`, `@.com`, `@foo`, `@corp-.com`, non-ASCII) raise `ReplyLayerError` with `.code == "INVALID_EMAIL"` (message: `"Invalid email or domain pattern"`).
402
422
 
403
- ### Blocked attempts (migration 038)
423
+ ### Blocked attempts
404
424
 
405
425
  Every send the allowlist gate rejects writes an append-only audit row and emits a deduped `recipient_allowlist.blocked_attempt` webhook. Review the log to see what your agent tried to email and one-click add legitimate recipients.
406
426
 
@@ -443,6 +463,28 @@ if not status["active"] and status["privacy_ok"]:
443
463
 
444
464
  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
465
 
466
+ ## Trusted instruction sources
467
+
468
+ 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.
469
+
470
+ 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:
471
+
472
+ ```python
473
+ result = rl.messages.wait(mailbox["id"])
474
+ msg = result["message"]
475
+ ctx = msg.get("agent_safety_context") if msg else None
476
+ if ctx and ctx.get("instruction_trust"):
477
+ # Gate passed: guidance was REPLACED with trusted-instruction wording (the
478
+ # agent may act on this verified sender's own explicit request in this
479
+ # message). untrusted_content is still True — this is metadata, not a
480
+ # content-safety judgment.
481
+ print(ctx["guidance"])
482
+ print(ctx["instruction_trust"])
483
+ # {"version": "v1", "match": "address", "verified_domain": ..., "verdict": "verified_aligned", "provenance": "mailgun"}
484
+ ```
485
+
486
+ 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.
487
+
446
488
  ## Mailbox identifiers
447
489
 
448
490
  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.
@@ -508,9 +550,9 @@ import json
508
550
  payload = json.loads(request.body)
509
551
  # payload["event"] is the discriminator — NOT payload["type"]
510
552
  if payload["event"] == "message.received":
511
- # handle inbound message
553
+ print("handle inbound message")
512
554
  elif payload["event"] == "message.dispatch_failed":
513
- # handle failed outbound send
555
+ print("handle failed outbound send")
514
556
  ```
515
557
 
516
558
  ## Context managers
@@ -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.1"
8
8
  description = "Official Python SDK for ReplyLayer — email for AI agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -17,6 +17,11 @@ dependencies = [
17
17
  "typing_extensions>=4.0",
18
18
  ]
19
19
 
20
+ [project.urls]
21
+ Homepage = "https://replylayer.ai"
22
+ Repository = "https://github.com/replylayer/rly"
23
+ Issues = "https://github.com/replylayer/rly/issues"
24
+
20
25
  [project.optional-dependencies]
21
26
  # Optional CLI convenience: `pip install "replylayer[cli]"` also installs the
22
27
  # `rly` launcher (the `rly` / `replylayer` command-line tools). The SDK itself
@@ -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.1"
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.1"
13
13
  _USER_AGENT = f"replylayer-sdk-py/{_VERSION}"
14
14
  _PROTECTED_HEADER_KEYS = frozenset({"authorization", "content-type", "user-agent"})
15
15
 
@@ -61,9 +61,9 @@ class WebhookSignatureError(ReplyLayerError):
61
61
 
62
62
 
63
63
  # Migration 040 — scheduled-send error surface. Raised when scheduled-send
64
- # routes reject a request (invalid TZ / too-soon / too-far / quota breach /
65
- # Idempotency-Key without send_at). Subclass of ReplyLayerError so generic
66
- # catches still work; typed .reason_code narrows for per-reason handling.
64
+ # routes reject a request (invalid TZ / too-soon / too-far / quota breach).
65
+ # Subclass of ReplyLayerError so generic catches still work; typed
66
+ # .reason_code narrows for per-reason handling.
67
67
  #
68
68
  # NOT raised for runtime dispatch failures — those surface as
69
69
  # message.dispatch_failed webhook events, not SDK exceptions.
@@ -72,7 +72,6 @@ _SCHEDULING_REASON_CODES: frozenset[str] = frozenset({
72
72
  "SEND_AT_TOO_SOON",
73
73
  "SEND_AT_TOO_FAR",
74
74
  "SCHEDULED_SEND_QUOTA_EXCEEDED",
75
- "IDEMPOTENCY_KEY_REQUIRES_SEND_AT",
76
75
  })
77
76
 
78
77
 
@@ -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'.
@@ -907,6 +950,7 @@ ScanCategory = Literal[
907
950
  "recipient_policy",
908
951
  "secret_detected",
909
952
  "content_similarity",
953
+ "delivery_warmup",
910
954
  "scan_incomplete",
911
955
  ]
912
956
 
@@ -973,6 +1017,26 @@ class ScanSummary(TypedDict):
973
1017
  findings: list[ScanFinding]
974
1018
 
975
1019
 
1020
+ class InstructionTrustBasis(TypedDict):
1021
+ """Trusted-instruction-sources — thin BASIS metadata on a trusted-instruction
1022
+ message (plan §4). Present ONLY when the customer designated this verified
1023
+ sender as a trusted instruction source AND the reading credential is a
1024
+ ``role='agent'`` key with the per-key capability enabled. Relaxation is
1025
+ gated purely by operator server-side config (mailbox instruction-trust
1026
+ mode + per-key capability + a live trusted-source grant) plus the
1027
+ message's authenticity/scan state — there is no client-sent opt-in header
1028
+ or constructor flag. The behavioural contract is in
1029
+ ``AgentSafetyContext.guidance`` (REPLACED on a trusted message); this is
1030
+ metadata only. ``untrusted_content`` stays True."""
1031
+
1032
+ version: Literal["v1"]
1033
+ match: Literal["address"]
1034
+ # Verified sender's domain; None under pii_mode=redacted.
1035
+ verified_domain: str | None
1036
+ verdict: Literal["verified_aligned"]
1037
+ provenance: Literal["mailgun"]
1038
+
1039
+
976
1040
  class AgentSafetyContext(TypedDict):
977
1041
  """Top-level standing safety contract on inbound message reads + the
978
1042
  ``message.received`` webhook. Present (untrusted_content=True) on every
@@ -981,6 +1045,41 @@ class AgentSafetyContext(TypedDict):
981
1045
 
982
1046
  untrusted_content: bool
983
1047
  guidance: str
1048
+ # Present ONLY on a trusted-instruction message (gate passed + opted-in
1049
+ # credential). When present, ``guidance`` carries the trusted-message
1050
+ # instruction; this is thin metadata, not the contract.
1051
+ instruction_trust: NotRequired[InstructionTrustBasis]
1052
+
1053
+
1054
+ # R5 — verified-sender verdict. High-confidence DOMAIN authenticity, NOT
1055
+ # certainty: ``verified_aligned`` means the message genuinely originated from
1056
+ # its From domain (Mailgun DMARC pass + alignment); it does NOT prove the
1057
+ # person/account, lookalike-safety, or content-safety, and never relaxes the
1058
+ # inbound-untrusted contract. Tolerant reader: treat an unknown value as
1059
+ # unverified. ``None`` (the whole object) = not evaluated.
1060
+ SenderAuthVerdict = Literal[
1061
+ "verified_aligned",
1062
+ "authenticated_unaligned",
1063
+ "failed",
1064
+ "none",
1065
+ "error",
1066
+ ]
1067
+
1068
+
1069
+ class SenderAuthentication(TypedDict):
1070
+ """Full verified-sender signal on the detail surface. ``from_domain`` +
1071
+ ``signing_domain`` are nulled under ``pii_mode='redacted'``."""
1072
+
1073
+ verdict: SenderAuthVerdict
1074
+ from_domain: str | None
1075
+ signing_domain: str | None
1076
+ provenance: Literal["mailgun", "self_hosted_imap"]
1077
+
1078
+
1079
+ class SenderAuthenticationCompact(TypedDict):
1080
+ """Compact verified-sender signal on the list/wait surfaces."""
1081
+
1082
+ verdict: SenderAuthVerdict
984
1083
 
985
1084
 
986
1085
  ScanView = Literal["summary", "verbose"]
@@ -1017,6 +1116,9 @@ class GetMessageResponse(TypedDict, total=False):
1017
1116
  attachments: Required[list[MessageAttachment]]
1018
1117
  scan: Required[ScanSummary | None]
1019
1118
  agent_safety_context: NotRequired["AgentSafetyContext | None"]
1119
+ # R5 — full verified-sender signal; domains nulled under pii_mode=redacted;
1120
+ # None = not evaluated.
1121
+ sender_authentication: NotRequired["SenderAuthentication | None"]
1020
1122
  # Governed Email-Effect Contract v1 (Track 2). Outbound-only derived VIEW;
1021
1123
  # OMITTED (key absent) on inbound rows (which carry agent_safety_context) and
1022
1124
  # on never-sent / pre-dispatch-transient outbound rows.
@@ -1650,6 +1752,9 @@ class ApiKeySummary(TypedDict):
1650
1752
  mailbox_ids: list[str]
1651
1753
  created_at: str
1652
1754
  last_used_at: str | None
1755
+ # Trusted-instruction-sources — the per-key opt-in capability (slice 6b).
1756
+ # Only an agent key can hold it. NotRequired for rollout tolerance.
1757
+ instruction_trust_enabled: NotRequired[bool]
1653
1758
 
1654
1759
 
1655
1760
  class ListApiKeysResponse(TypedDict):
@@ -1711,12 +1816,26 @@ class QuotaToday(TypedDict):
1711
1816
  day: str
1712
1817
 
1713
1818
 
1819
+ # Present ONLY while a new paid account is inside its shared-domain warm-up
1820
+ # window (paid-velocity-gate); absent for every other account/state. `until`
1821
+ # is ISO-8601; `shared_domain_daily_limit` is the temporary daily send cap on
1822
+ # the shared platform pool; `velocity_gate_mode` reflects whether the inline
1823
+ # velocity control is enforcing yet; `reason` is customer-safe copy. Verify
1824
+ # your own sending domain (BYOD) to lift the warm-up immediately.
1825
+ class QuotaWarmup(TypedDict):
1826
+ until: str
1827
+ shared_domain_daily_limit: int
1828
+ velocity_gate_mode: Literal["log_only", "enforced"]
1829
+ reason: str
1830
+
1831
+
1714
1832
  class QuotaResponse(TypedDict):
1715
1833
  today: QuotaToday
1716
1834
  sends_remaining: int
1717
1835
  reset_at: str
1718
1836
  scope: Literal["admin", "agent"]
1719
1837
  bound_mailbox_ids: list[str]
1838
+ warmup: NotRequired[QuotaWarmup]
1720
1839
 
1721
1840
 
1722
1841
  # Malicious link scanning (URL reputation, backed by Google Web Risk).
@@ -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():
@@ -472,19 +472,19 @@ def test_drafts_create_429_quota_exceeded_maps_to_scheduling_error_not_rate_limi
472
472
 
473
473
 
474
474
  @respx.mock
475
- def test_drafts_create_idempotency_key_without_send_at_maps_to_scheduling_error():
475
+ def test_drafts_create_send_at_too_far_maps_to_scheduling_error():
476
476
  respx.post(f"{BASE}/v1/drafts").mock(
477
477
  return_value=httpx.Response(400, json={
478
- "error": "Idempotency-Key is only accepted when send_at is present",
479
- "code": "IDEMPOTENCY_KEY_REQUIRES_SEND_AT",
478
+ "error": "send_at is beyond the scheduling horizon",
479
+ "code": "SEND_AT_TOO_FAR",
480
480
  }),
481
481
  )
482
482
  with pytest.raises(SchedulingError) as excinfo:
483
483
  sdk().drafts.create(
484
484
  mailbox_id="mbx1", to="r@example.com", subject="x", body="y",
485
- idempotency_key="k1",
485
+ send_at="2099-01-01T09:00:00Z",
486
486
  )
487
- assert excinfo.value.reason_code == "IDEMPOTENCY_KEY_REQUIRES_SEND_AT"
487
+ assert excinfo.value.reason_code == "SEND_AT_TOO_FAR"
488
488
 
489
489
 
490
490
  @respx.mock
@@ -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
@@ -0,0 +1,80 @@
1
+ """B6 parity gate — the README "Resources" table must list EVERY public method
2
+ of EVERY resource the client exposes, and nothing that isn't a real method.
3
+
4
+ A stale table is the failure this locks out (the docs had drifted to
5
+ "suppressions has only list/delete" and "messages has 7 of 15 methods").
6
+ Reflect over a live client, parse the README table, and assert set equality so
7
+ adding/removing a method fails CI until the table is regenerated.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import inspect
13
+ import re
14
+ from pathlib import Path
15
+
16
+ from replylayer import ReplyLayer
17
+
18
+ README_PATH = Path(__file__).resolve().parent.parent / "README.md"
19
+
20
+
21
+ def _method_names(obj: object) -> list[str]:
22
+ """Public bound-method names on a resource instance, sorted.
23
+
24
+ ``inspect.ismethod`` returns only bound methods, so ``@staticmethod``
25
+ helpers (e.g. ``webhooks.verify_signature``) are excluded — matching the
26
+ table, which documents the top-level ``verify_webhook_signature`` helper
27
+ separately, not as a resource method.
28
+ """
29
+ return sorted(
30
+ name
31
+ for name, _ in inspect.getmembers(obj, predicate=inspect.ismethod)
32
+ if not name.startswith("_")
33
+ )
34
+
35
+
36
+ def _build_actual() -> dict[str, list[str]]:
37
+ out: dict[str, list[str]] = {}
38
+ with ReplyLayer(api_key="rly_live_test") as client:
39
+ for res_name, resource in vars(client).items():
40
+ if res_name.startswith("_"):
41
+ continue
42
+ out[res_name] = _method_names(resource)
43
+ # One level of nested resources (e.g. mailboxes.allowlist). The
44
+ # shared HTTP client is stored as the private ``_http`` attribute
45
+ # and is filtered out by the underscore guard.
46
+ for sub_name, sub in vars(resource).items():
47
+ if sub_name.startswith("_"):
48
+ continue
49
+ if _method_names(sub):
50
+ out[f"{res_name}.{sub_name}"] = _method_names(sub)
51
+ return out
52
+
53
+
54
+ def _parse_readme_table(md: str) -> dict[str, list[str]]:
55
+ out: dict[str, list[str]] = {}
56
+ for raw in md.splitlines():
57
+ line = raw.strip()
58
+ if not line.startswith("|"):
59
+ continue
60
+ cells = [c.strip() for c in line.split("|")]
61
+ resource_cell = cells[1] if len(cells) > 1 else ""
62
+ m = re.match(r"^`rl\.(.+)`$", resource_cell)
63
+ if not m: # header / separator / non-resource rows
64
+ continue
65
+ methods_cell = cells[2] if len(cells) > 2 else ""
66
+ out[m.group(1)] = sorted(re.findall(r"`([^`]+)`", methods_cell))
67
+ return out
68
+
69
+
70
+ _ACTUAL = _build_actual()
71
+ _DOCUMENTED = _parse_readme_table(README_PATH.read_text(encoding="utf-8"))
72
+
73
+
74
+ def test_readme_documents_all_resources() -> None:
75
+ assert sorted(_DOCUMENTED) == sorted(_ACTUAL)
76
+
77
+
78
+ def test_readme_lists_all_methods() -> None:
79
+ for key, methods in _ACTUAL.items():
80
+ assert _DOCUMENTED.get(key) == methods, f"methods for rl.{key}"
@@ -53,6 +53,21 @@ def test_account_get_usage_includes_storage():
53
53
  assert res["storage"]["breakdown"]["derivative_bytes"] == 34
54
54
 
55
55
 
56
+ QUOTA_RESPONSE_WARMUP = {
57
+ "today": {"count": 2, "limit": 233, "day": "2026-07-01"},
58
+ "sends_remaining": 231,
59
+ "reset_at": "2026-07-02T00:00:00.000Z",
60
+ "scope": "admin",
61
+ "bound_mailbox_ids": [],
62
+ "warmup": {
63
+ "until": "2026-07-04T12:00:00.000Z",
64
+ "shared_domain_daily_limit": 233,
65
+ "velocity_gate_mode": "log_only",
66
+ "reason": "New paid accounts ramp to full sending volume over their first few days on the shared pool.",
67
+ },
68
+ }
69
+
70
+
56
71
  @respx.mock
57
72
  def test_account_get_quota_returns_send_budget():
58
73
  route = respx.get(f"{BASE}/v1/accounts/quota").mock(return_value=httpx.Response(200, json=QUOTA_RESPONSE))
@@ -63,6 +78,21 @@ def test_account_get_quota_returns_send_budget():
63
78
  assert res["reset_at"] == "2026-05-31T00:00:00.000Z"
64
79
  assert res["scope"] == "agent"
65
80
  assert res["bound_mailbox_ids"] == ["mb-1", "mb-2"]
81
+ # `warmup` is optional and absent for a non-warm-up account.
82
+ assert "warmup" not in res
83
+
84
+
85
+ @respx.mock
86
+ def test_account_get_quota_passes_through_warmup():
87
+ route = respx.get(f"{BASE}/v1/accounts/quota").mock(
88
+ return_value=httpx.Response(200, json=QUOTA_RESPONSE_WARMUP)
89
+ )
90
+ res = sdk().account.get_quota()
91
+ assert route.called
92
+ assert res["warmup"]["until"] == "2026-07-04T12:00:00.000Z"
93
+ assert res["warmup"]["shared_domain_daily_limit"] == 233
94
+ assert res["warmup"]["velocity_gate_mode"] == "log_only"
95
+ assert "ramp" in res["warmup"]["reason"]
66
96
 
67
97
 
68
98
  @respx.mock
@@ -550,6 +580,51 @@ def test_set_recipient_policy_passes_scanner_policy_when_provided():
550
580
  }
551
581
 
552
582
 
583
+ # ── Single "agent sends" control — set_agent_send_policy body shape (sync) ──
584
+ # plans/agent-sends-policy-control-2026-06-26.md.
585
+
586
+
587
+ @respx.mock
588
+ def test_set_agent_send_policy_sends_policy_alone_without_confirm():
589
+ """The convenience helper sends ONLY agent_send_policy when no confirm flag
590
+ is passed — confirm_open_human_sends must NOT ride along (default False)."""
591
+ route = respx.patch(f"{BASE}/v1/mailboxes/m1").mock(
592
+ return_value=httpx.Response(200, json={"id": "m1", "name": "support"}),
593
+ )
594
+ sdk().mailboxes.set_agent_send_policy("m1", "restricted")
595
+ payload = json.loads(route.calls[0].request.content)
596
+ assert payload == {"agent_send_policy": "restricted"}
597
+ assert "confirm_open_human_sends" not in payload
598
+
599
+
600
+ @respx.mock
601
+ def test_set_agent_send_policy_includes_confirm_only_when_true():
602
+ """confirm_open_human_sends=True is forwarded; the consent flag rides along
603
+ only on the open-allowlist confirmation path."""
604
+ route = respx.patch(f"{BASE}/v1/mailboxes/m1").mock(
605
+ return_value=httpx.Response(200, json={"id": "m1", "name": "support"}),
606
+ )
607
+ sdk().mailboxes.set_agent_send_policy(
608
+ "m1", "open", confirm_open_human_sends=True,
609
+ )
610
+ payload = json.loads(route.calls[0].request.content)
611
+ assert payload == {"agent_send_policy": "open", "confirm_open_human_sends": True}
612
+
613
+
614
+ @respx.mock
615
+ def test_set_agent_send_policy_omits_confirm_when_false():
616
+ """A falsy confirm_open_human_sends does NOT add the flag to the body."""
617
+ route = respx.patch(f"{BASE}/v1/mailboxes/m1").mock(
618
+ return_value=httpx.Response(200, json={"id": "m1", "name": "support"}),
619
+ )
620
+ sdk().mailboxes.set_agent_send_policy(
621
+ "m1", "restricted", confirm_open_human_sends=False,
622
+ )
623
+ payload = json.loads(route.calls[0].request.content)
624
+ assert payload == {"agent_send_policy": "restricted"}
625
+ assert "confirm_open_human_sends" not in payload
626
+
627
+
553
628
  # ── PR 8.1 — pii_redaction_config request forwarding (round-5 audit fix #3) ──
554
629
 
555
630
 
File without changes
File without changes