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.
- replylayer-0.21.0/README.md → replylayer-0.22.1/PKG-INFO +91 -12
- replylayer-0.21.0/PKG-INFO → replylayer-0.22.1/README.md +71 -29
- {replylayer-0.21.0 → replylayer-0.22.1}/pyproject.toml +6 -1
- {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/__init__.py +17 -1
- {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/_http.py +1 -1
- {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/errors.py +3 -4
- {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/mailboxes.py +101 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/types.py +119 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_async.py +53 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_drafts.py +5 -5
- replylayer-0.22.1/tests/test_instruction_trust.py +37 -0
- replylayer-0.22.1/tests/test_readme_resource_parity.py +80 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_resources.py +75 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/.gitignore +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/__main__.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/_client.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/_pagination.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/py.typed +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/__init__.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/account.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/api_keys.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/attachments.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/domains.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/drafts.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/health.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/inbound_blocklist.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/legal_holds.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/messages.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/recipients.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/suppressions.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/threads.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/replylayer/resources/webhooks.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/tests/__init__.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_attachments.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_client.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_domains.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_governed_email_effect.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_hitl_review_types.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_http.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_messages_idempotency.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_threads.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_version.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_web_risk_types.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_webhooks.py +0 -0
- {replylayer-0.21.0 → replylayer-0.22.1}/tests/test_ws1_ws6.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
573
|
+
print("handle inbound message")
|
|
495
574
|
elif payload["event"] == "message.dispatch_failed":
|
|
496
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
553
|
+
print("handle inbound message")
|
|
512
554
|
elif payload["event"] == "message.dispatch_failed":
|
|
513
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
@@ -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
|
-
#
|
|
66
|
-
#
|
|
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
|
|
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": "
|
|
479
|
-
"code": "
|
|
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
|
-
|
|
485
|
+
send_at="2099-01-01T09:00:00Z",
|
|
486
486
|
)
|
|
487
|
-
assert excinfo.value.reason_code == "
|
|
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
|
|
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
|