replylayer 0.22.1__tar.gz → 0.26.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {replylayer-0.22.1 → replylayer-0.26.0}/.gitignore +5 -1
- {replylayer-0.22.1 → replylayer-0.26.0}/PKG-INFO +108 -12
- {replylayer-0.22.1 → replylayer-0.26.0}/README.md +107 -11
- {replylayer-0.22.1 → replylayer-0.26.0}/pyproject.toml +1 -1
- {replylayer-0.22.1 → replylayer-0.26.0}/replylayer/__init__.py +38 -1
- {replylayer-0.22.1 → replylayer-0.26.0}/replylayer/_client.py +6 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/replylayer/_http.py +1 -1
- {replylayer-0.22.1 → replylayer-0.26.0}/replylayer/resources/api_keys.py +16 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/replylayer/resources/drafts.py +10 -2
- {replylayer-0.22.1 → replylayer-0.26.0}/replylayer/resources/mailboxes.py +119 -13
- replylayer-0.26.0/replylayer/resources/policy.py +161 -0
- replylayer-0.26.0/replylayer/resources/simulator.py +39 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/replylayer/types.py +324 -31
- {replylayer-0.22.1 → replylayer-0.26.0}/tests/test_async.py +48 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/tests/test_client.py +1 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/tests/test_hitl_review_types.py +25 -1
- {replylayer-0.22.1 → replylayer-0.26.0}/tests/test_instruction_trust.py +1 -1
- {replylayer-0.22.1 → replylayer-0.26.0}/tests/test_resources.py +139 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/uv.lock +4 -4
- {replylayer-0.22.1 → replylayer-0.26.0}/replylayer/__main__.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/replylayer/_pagination.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/replylayer/errors.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/replylayer/py.typed +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/replylayer/resources/__init__.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/replylayer/resources/account.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/replylayer/resources/attachments.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/replylayer/resources/domains.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/replylayer/resources/health.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/replylayer/resources/inbound_blocklist.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/replylayer/resources/legal_holds.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/replylayer/resources/messages.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/replylayer/resources/recipients.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/replylayer/resources/suppressions.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/replylayer/resources/threads.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/replylayer/resources/webhooks.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/tests/__init__.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/tests/test_attachments.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/tests/test_domains.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/tests/test_drafts.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/tests/test_governed_email_effect.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/tests/test_http.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/tests/test_messages_idempotency.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/tests/test_readme_resource_parity.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/tests/test_threads.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/tests/test_version.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/tests/test_web_risk_types.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/tests/test_webhooks.py +0 -0
- {replylayer-0.22.1 → replylayer-0.26.0}/tests/test_ws1_ws6.py +0 -0
|
@@ -4,7 +4,11 @@ dist/
|
|
|
4
4
|
.env.*
|
|
5
5
|
*.env
|
|
6
6
|
!.env.example
|
|
7
|
-
.claude
|
|
7
|
+
# .claude is session-local (worktrees, local settings) EXCEPT the shared
|
|
8
|
+
# subagent definitions, which are repo-versioned (plays with `.claude/*`
|
|
9
|
+
# rather than `.claude/` so the re-include below can take effect).
|
|
10
|
+
.claude/*
|
|
11
|
+
!.claude/agents/
|
|
8
12
|
.antigravitycli/
|
|
9
13
|
*.log
|
|
10
14
|
coverage/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: replylayer
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.26.0
|
|
4
4
|
Summary: Official Python SDK for ReplyLayer — email for AI agents
|
|
5
5
|
Project-URL: Homepage, https://replylayer.ai
|
|
6
6
|
Project-URL: Repository, https://github.com/replylayer/rly
|
|
@@ -164,13 +164,45 @@ may no longer be available. The async client exposes the same methods.
|
|
|
164
164
|
| `rl.recipients` | `create`, `list`, `delete`, `resend` |
|
|
165
165
|
| `rl.suppressions` | `list`, `add`, `add_bulk`, `delete` |
|
|
166
166
|
| `rl.inbound_blocklist` | `list`, `add`, `add_bulk`, `delete` |
|
|
167
|
-
| `rl.api_keys` | `create`, `list`, `revoke`, `rotate`* |
|
|
167
|
+
| `rl.api_keys` | `create`, `list`, `update`, `revoke`, `rotate`* |
|
|
168
168
|
| `rl.account` | `get_usage`, `get_quota`, `get_link_scanning_status`, `enable_link_scanning`, `export` |
|
|
169
169
|
| `rl.legal_holds` | `apply`, `release`, `list`, `get` |
|
|
170
170
|
| `rl.health` | `check` |
|
|
171
|
+
| `rl.simulator` | `inject_inbound` |
|
|
172
|
+
| `rl.policy` | `get_mailbox_policy`, `get_overview`, `get_account_policy`, `update_account_policy`, `preview_mailbox_policy` |
|
|
171
173
|
|
|
172
174
|
*`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.
|
|
173
175
|
|
|
176
|
+
## Simulator
|
|
177
|
+
|
|
178
|
+
Outbound scenarios use the normal send methods. Inbound scenarios use
|
|
179
|
+
`rl.simulator.inject_inbound()`:
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
outbound = rl.messages.send(
|
|
183
|
+
from_mailbox=mailbox["name"],
|
|
184
|
+
to="delivered+ci-run-42@simulator.replylayer.net",
|
|
185
|
+
subject="simulator check",
|
|
186
|
+
body="exercise the delivered path",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
inbound = rl.simulator.inject_inbound({
|
|
190
|
+
"mailbox_id": mailbox["id"],
|
|
191
|
+
"scenario": "clean",
|
|
192
|
+
"label": "ci-run-42",
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
# Branch on the result: available | quarantined | pending.
|
|
196
|
+
print(outbound["message_id"], inbound["status"], inbound.get("message_id"))
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Outbound delivery, bounce, complaint, and suppression addresses; delayed webhook
|
|
200
|
+
outcomes; inbound scenario semantics; and billing/suppression caveats are defined in
|
|
201
|
+
the [email simulator guide](https://replylayer.ai/docs/guides/simulator).
|
|
202
|
+
One Sandbox account can run all four exact outbound scenarios in the same day; those
|
|
203
|
+
addresses bypass destination-concentration controls but still consume normal
|
|
204
|
+
daily/cumulative usage allowance.
|
|
205
|
+
|
|
174
206
|
## Drafts: scan-then-review-then-send
|
|
175
207
|
|
|
176
208
|
`rl.drafts.create()` runs the outbound scanner synchronously and attaches the verdict to the draft. The create-time verdict is UX — it lets an agent (or a human approver) see the likely outcome before clicking send. `rl.drafts.send()` **re-runs the scanner authoritatively** against the mailbox's current policy, so a stale cached verdict cannot slip through.
|
|
@@ -187,12 +219,12 @@ if draft["worst_decision"] == "allow":
|
|
|
187
219
|
print(f"Sent {result['message_id']}")
|
|
188
220
|
```
|
|
189
221
|
|
|
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/
|
|
222
|
+
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", "review_causes"?, "agent_instructions"}` or `None`) is the policy/human-review reason plus held-send guidance, non-None on held sends — a typed policy cause (first-contact/send-window/Supervised), a policy/human-review hold that changed the scanner's decision, or a genuine scan-explained hold such as a real scanner quarantine (`trigger_source`: `mailbox_policy` | `scanner` | `both`); it stays `None` on `sent`/terminal outcomes and normally on retryable infrastructure holds — a typed policy cause still attaches one (branch on `email_effect["effect_status"]` for those). `review_causes` is the typed hold-cause discriminator (`content_warning` | `first_contact` | `send_window` | `mailbox_policy`) driving a Supervised (`risky_only`) hold or a send-window promotion — a `NotRequired` key, absent on holds this dashboard policy builder didn't cause-type.
|
|
191
223
|
|
|
192
224
|
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.)
|
|
193
225
|
|
|
194
226
|
The send endpoint raises `ReplyLayerError` with distinct `.code` values on 409:
|
|
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/
|
|
227
|
+
- `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/human-review decision drove the hold, `hold_context`.
|
|
196
228
|
- `DRAFT_ALREADY_SENT` — the draft was already sent (race or retry after success).
|
|
197
229
|
|
|
198
230
|
```python
|
|
@@ -361,10 +393,39 @@ rl.mailboxes.update(
|
|
|
361
393
|
)
|
|
362
394
|
```
|
|
363
395
|
|
|
364
|
-
Supported actions are `"allow"`, `"allow_with_warning"`, `"review"`, `"quarantine"`, and `"block"`. `"review"` routes matching sends to
|
|
396
|
+
Supported actions are `"allow"`, `"allow_with_warning"`, `"review"`, `"quarantine"`, and `"block"`. `"review"` routes matching sends to your review queue (approve/deny) — available on every tier. Relaxing below platform defaults requires Pro+ (`pii_advanced_controls`); default or stricter values are accepted on every tier. Outbound PII scan results include `pii_type` (`"ssn"`, `"credit_card"`, or `"phone_number"`) so clients can inspect which type drove the action.
|
|
365
397
|
|
|
366
398
|
Approval notes are optional by default. Set `outbound_review_policy.approval_note` to `"required_for_sensitive_pii"` when approvers must add a note before sending SSN or credit-card review holds.
|
|
367
399
|
|
|
400
|
+
### Mailbox policy fields (dashboard policy builder)
|
|
401
|
+
|
|
402
|
+
`rl.mailboxes.update()` also accepts the per-mailbox fields the dashboard policy builder governs: `agent_authoring_mode` (`"send_and_draft"` | `"draft_only"` | `"read_only"`), `hitl_mode` (now widened to `"disabled"` | `"all_outbound"` | `"risky_only"`), `approval_expiry` (`"24h"` | `"72h"` | `"7d"` | `"never"`), and `send_window` (a dict binding AGENT-origin sends to a weekly window). `apply_policy_mode` applies one of the four named modes (`"read_only"` / `"draft_only"` / `"supervised"` / `"trusted"`) atomically and is mutually exclusive with those raw identity fields in the same call (the server returns `400 AMBIGUOUS_POLICY_MODE_APPLICATION`).
|
|
403
|
+
|
|
404
|
+
```python
|
|
405
|
+
# Apply a named mode — writes agent_authoring_mode/hitl_mode/agent_send_policy together.
|
|
406
|
+
rl.mailboxes.update(mailbox["id"], apply_policy_mode="supervised")
|
|
407
|
+
|
|
408
|
+
# Or set fields directly (not combined with apply_policy_mode in the same call).
|
|
409
|
+
rl.mailboxes.update(
|
|
410
|
+
mailbox["id"],
|
|
411
|
+
agent_authoring_mode="draft_only",
|
|
412
|
+
approval_expiry="72h",
|
|
413
|
+
send_window={
|
|
414
|
+
"timezone": "America/Chicago",
|
|
415
|
+
"days": ["mon", "tue", "wed", "thu", "fri"],
|
|
416
|
+
"start": "09:00",
|
|
417
|
+
"end": "18:00",
|
|
418
|
+
"outside_action": "require_approval",
|
|
419
|
+
},
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# send_window omitted (default) leaves it unchanged; pass send_window=None
|
|
423
|
+
# explicitly to CLEAR it (always-open — a loosening).
|
|
424
|
+
rl.mailboxes.update(mailbox["id"], send_window=None)
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
**Direction gate.** Tightening (toward `read_only`/shorter expiry/narrower window) works with an admin API key. Any loosening requires a dashboard session + fresh re-auth — a bearer key gets `403 REAUTH_REQUIRES_SESSION`. `rl.policy.get_mailbox_policy(mailbox_id)` reads the derived `policy_mode`, `last_applied_policy_mode`, the calling key's `binding["permitted_verbs"]`, and `enforcement` — the live rollout-lever state (`{"risky_only": ..., "send_window": ...}`, each `"off"` | `"shadow"` | `"enforce"`). A stored `hitl_mode="risky_only"` or `send_window` only actually holds a send when the matching `enforcement` field reads `"enforce"`; `off`/`shadow` mean the posture is saved but not yet active. `rl.policy.get_overview()` carries the same `enforcement` block once, account-wide (the levers are env-global, not per-mailbox). `rl.policy.preview_mailbox_policy(mailbox_id, to=...)` is a side-effect-free dry-run of the gate stack for a sample send.
|
|
428
|
+
|
|
368
429
|
### Agent Attachment Access
|
|
369
430
|
|
|
370
431
|
Effective attachment exposure now comes from the mailbox policy surface (`attachment_exposure_mode` plus `attachment_allowed_file_families`), not from the legacy `attachment_access_enabled` boolean alone. Admin keys, pre-scoping keys, and dashboard sessions still bypass the agent mailbox-policy gate. Agent-key download requests without an explicit raw-download policy return 403 `ATTACHMENT_ACCESS_DISABLED` — surfaced as `ReplyLayerError` with `.code == "ATTACHMENT_ACCESS_DISABLED"`:
|
|
@@ -387,11 +448,11 @@ Images are a separately confirmed raw-download family. When `allowed_file_famili
|
|
|
387
448
|
|
|
388
449
|
Human dashboard sessions and admin/pre-scoping keys can download clean stored `metadata_only` attachments, including attachments held back from agent raw-download policy. Agent-role keys remain bound to the mailbox policy gate plus hard safety checks; all callers remain blocked by infected AV verdicts, non-terminal message states, missing stored bytes, and hard attachment blocks.
|
|
389
450
|
|
|
390
|
-
See
|
|
451
|
+
See the Mailboxes API reference at https://replylayer.ai/docs/api/mailboxes for the full contract and known limitations.
|
|
391
452
|
|
|
392
453
|
### Recipient allowlist (mailbox containment)
|
|
393
454
|
|
|
394
|
-
A mailbox is in `blocklist` mode by default — the pre-send gate rejects `suppressed_addresses` hits and allows everyone else. Switching to `allowlist` mode
|
|
455
|
+
A mailbox is in `blocklist` mode by default — the pre-send gate rejects `suppressed_addresses` hits and allows everyone else. Switching to `allowlist` mode contains **agent-origin** outbound to a pre-approved list (plus thread participants): a prompt-injected or compromised **agent key** cannot email outside the list. It is a containment boundary against a hijacked agent, not an all-origin lock — a human send (your dashboard session or an admin API key) is not restricted by the allowlist; only your do-not-contact (suppression) list binds a human send.
|
|
395
456
|
|
|
396
457
|
```python
|
|
397
458
|
# Populate the allowlist first. Admin-only — agent keys get 403 INSUFFICIENT_SCOPE.
|
|
@@ -413,7 +474,9 @@ rl.mailboxes.allowlist.delete(mailbox["id"], "partner@corp.com", force_empty=Tru
|
|
|
413
474
|
|
|
414
475
|
A send/reply/draft-send to a recipient on your do-not-contact (suppression) list raises `ReplyLayerError` with `.code == "RECIPIENT_SUPPRESSED"` (HTTP 403, `details["reason"] == "suppressed"`). This is terminal — escalate, don't retry; remove the suppression or send to a different recipient.
|
|
415
476
|
|
|
416
|
-
|
|
477
|
+
The same `RECIPIENT_SUPPRESSED` code also fires for a **platform-scoped** cross-account hard-bounce hit — an address that hard-bounced somewhere on the platform (not necessarily on your account), which ReplyLayer refuses on every ReplyLayer-managed sending domain to protect the shared reputation every customer rides on (never enforced on a delegated/BYOD or self-hosted domain). `details["scope"] == "platform"` distinguishes it from your own list (which omits `scope`); this variant isn't in `GET /v1/suppressions` and can't be removed via the SDK — it's an operator-only override.
|
|
478
|
+
|
|
479
|
+
Allowlist mutations are admin-only — granting mutation to an LLM defeats the agent-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`.
|
|
417
480
|
|
|
418
481
|
### Domain entries
|
|
419
482
|
|
|
@@ -466,9 +529,42 @@ Webhook deliveries are deduped server-side to at most one per `(account, mailbox
|
|
|
466
529
|
|
|
467
530
|
The MCP tool `list_allowlist_blocked_attempts` exposes the same view to agents — read-only by design. There is no dismiss-attempt tool (the containment boundary would be moot if an agent could clear its own rejection history).
|
|
468
531
|
|
|
532
|
+
## Recipient verification (send-path safety checks)
|
|
533
|
+
|
|
534
|
+
`POST /v1/messages/send`, `.../reply`, and `POST /v1/drafts/:id/send` run a permissive, fail-open recipient-quality check before dispatch. Only a **confirmed** violation rejects the send — an infrastructure hiccup (DNS blip, verification service outage) never blocks it. A reply or thread continuation is exempt (the recipient is a proven correspondent). Five `ReplyLayerError` codes, all HTTP 422:
|
|
535
|
+
|
|
536
|
+
| Code | Meaning |
|
|
537
|
+
|---|---|
|
|
538
|
+
| `RECIPIENT_ADDRESS_INVALID` | The address fails a strict syntax check beyond the basic email-format validation. |
|
|
539
|
+
| `RECIPIENT_DOMAIN_TYPO_SUSPECTED` | The domain looks like a single-character typo of a common consumer mail provider (e.g. `gmial.com`). The suggested domain rides in the exception message. |
|
|
540
|
+
| `RECIPIENT_ROLE_ADDRESS` | The local part is a structural role/distribution mailbox (`noreply@`, `no-reply@`, etc. by default), not an individual inbox. |
|
|
541
|
+
| `RECIPIENT_DISPOSABLE_ADDRESS` | The domain is a known disposable/temporary email provider. |
|
|
542
|
+
| `RECIPIENT_UNDELIVERABLE` | The domain has no mail servers (no MX or A record) — mail to it would hard-bounce. |
|
|
543
|
+
|
|
544
|
+
```python
|
|
545
|
+
from replylayer import ValidationError
|
|
546
|
+
|
|
547
|
+
try:
|
|
548
|
+
rl.messages.send(from_mailbox="support", to="noreply@example.com", subject="hi", body="x")
|
|
549
|
+
except ValidationError as err:
|
|
550
|
+
if err.code == "RECIPIENT_ROLE_ADDRESS":
|
|
551
|
+
print("That looks like a role mailbox, not a person — double-check the recipient.")
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
## Mailbox creation & sending-domain provisioning
|
|
555
|
+
|
|
556
|
+
On paid accounts (once the per-account sending-domain estate is enabled), `rl.mailboxes.create(...)` can race the account's sending-domain setup. There are no new SDK methods — two error codes flow through the existing error envelope:
|
|
557
|
+
|
|
558
|
+
| Code | HTTP | Meaning |
|
|
559
|
+
|---|---|---|
|
|
560
|
+
| `DOMAIN_PROVISIONING_PENDING` | 409 | The account's sending domain is still being set up (or a domain change is in flight). Retryable — `err.details["retry_after"]` carries the suggested seconds. Poll `rl.domains.list()` for the platform row's `verification_status`. |
|
|
561
|
+
| `DOMAIN_PROVISIONING_FAILED` | 409 | Sending-domain setup failed. Not retryable from the client — contact support. |
|
|
562
|
+
|
|
563
|
+
The SDK deliberately does **not** auto-retry; retry loops live in the CLI (`rly mailbox create` waits up to 60s) and the dashboard.
|
|
564
|
+
|
|
469
565
|
## Malicious link scanning (URL reputation)
|
|
470
566
|
|
|
471
|
-
Malicious link scanning checks inbound links against Google Web Risk (only SHA-256 hash-prefixes are sent — full URLs never leave the platform).
|
|
567
|
+
Malicious link scanning checks inbound links against Google Web Risk (only SHA-256 hash-prefixes are sent — full URLs never leave the platform). New accounts have it enabled by default at signup (disclosed in Privacy Policy §7a; per-mailbox opt-out via the mailbox scanner policy); older accounts opt in per account. Enabling it is an **admin action** (it turns on an account-wide sub-processor data flow), so agent-scoped keys can read status but not enable it (they raise `ForbiddenError`).
|
|
472
568
|
|
|
473
569
|
Pass the version you are acknowledging explicitly; the SDK does not auto-fetch it, so the consent is recorded against a version your code chose:
|
|
474
570
|
|
|
@@ -481,7 +577,7 @@ if not status["active"] and status["privacy_ok"]:
|
|
|
481
577
|
# res["url_reputation"]["active"] is True; res["disclosure"]["notice"] / ["advisory_url"]
|
|
482
578
|
```
|
|
483
579
|
|
|
484
|
-
If `privacy_ok` is `False` the account's
|
|
580
|
+
If `privacy_ok` is `False` the account's acknowledged privacy policy version predates the disclosed sub-processor — review and acknowledge 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(...)`).
|
|
485
581
|
|
|
486
582
|
## Trusted instruction sources
|
|
487
583
|
|
|
@@ -500,7 +596,7 @@ if ctx and ctx.get("instruction_trust"):
|
|
|
500
596
|
# content-safety judgment.
|
|
501
597
|
print(ctx["guidance"])
|
|
502
598
|
print(ctx["instruction_trust"])
|
|
503
|
-
# {"version": "v1", "match": "address", "verified_domain": ..., "verdict": "verified_aligned", "provenance": "
|
|
599
|
+
# {"version": "v1", "match": "address", "verified_domain": ..., "verdict": "verified_aligned", "provenance": "managed"}
|
|
504
600
|
```
|
|
505
601
|
|
|
506
602
|
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.
|
|
@@ -549,7 +645,7 @@ Error classes: `ReplyLayerError` (base), `AuthenticationError` (401), `Forbidden
|
|
|
549
645
|
|
|
550
646
|
## Webhook signature verification
|
|
551
647
|
|
|
552
|
-
> For a full integration guide (event catalog, retry behavior, idempotency, security, troubleshooting), see
|
|
648
|
+
> For a full integration guide (event catalog, retry behavior, idempotency, security, troubleshooting), see https://replylayer.ai/docs/webhooks.
|
|
553
649
|
|
|
554
650
|
```python
|
|
555
651
|
from replylayer import verify_webhook_signature
|
|
@@ -144,13 +144,45 @@ may no longer be available. The async client exposes the same methods.
|
|
|
144
144
|
| `rl.recipients` | `create`, `list`, `delete`, `resend` |
|
|
145
145
|
| `rl.suppressions` | `list`, `add`, `add_bulk`, `delete` |
|
|
146
146
|
| `rl.inbound_blocklist` | `list`, `add`, `add_bulk`, `delete` |
|
|
147
|
-
| `rl.api_keys` | `create`, `list`, `revoke`, `rotate`* |
|
|
147
|
+
| `rl.api_keys` | `create`, `list`, `update`, `revoke`, `rotate`* |
|
|
148
148
|
| `rl.account` | `get_usage`, `get_quota`, `get_link_scanning_status`, `enable_link_scanning`, `export` |
|
|
149
149
|
| `rl.legal_holds` | `apply`, `release`, `list`, `get` |
|
|
150
150
|
| `rl.health` | `check` |
|
|
151
|
+
| `rl.simulator` | `inject_inbound` |
|
|
152
|
+
| `rl.policy` | `get_mailbox_policy`, `get_overview`, `get_account_policy`, `update_account_policy`, `preview_mailbox_policy` |
|
|
151
153
|
|
|
152
154
|
*`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.
|
|
153
155
|
|
|
156
|
+
## Simulator
|
|
157
|
+
|
|
158
|
+
Outbound scenarios use the normal send methods. Inbound scenarios use
|
|
159
|
+
`rl.simulator.inject_inbound()`:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
outbound = rl.messages.send(
|
|
163
|
+
from_mailbox=mailbox["name"],
|
|
164
|
+
to="delivered+ci-run-42@simulator.replylayer.net",
|
|
165
|
+
subject="simulator check",
|
|
166
|
+
body="exercise the delivered path",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
inbound = rl.simulator.inject_inbound({
|
|
170
|
+
"mailbox_id": mailbox["id"],
|
|
171
|
+
"scenario": "clean",
|
|
172
|
+
"label": "ci-run-42",
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
# Branch on the result: available | quarantined | pending.
|
|
176
|
+
print(outbound["message_id"], inbound["status"], inbound.get("message_id"))
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Outbound delivery, bounce, complaint, and suppression addresses; delayed webhook
|
|
180
|
+
outcomes; inbound scenario semantics; and billing/suppression caveats are defined in
|
|
181
|
+
the [email simulator guide](https://replylayer.ai/docs/guides/simulator).
|
|
182
|
+
One Sandbox account can run all four exact outbound scenarios in the same day; those
|
|
183
|
+
addresses bypass destination-concentration controls but still consume normal
|
|
184
|
+
daily/cumulative usage allowance.
|
|
185
|
+
|
|
154
186
|
## Drafts: scan-then-review-then-send
|
|
155
187
|
|
|
156
188
|
`rl.drafts.create()` runs the outbound scanner synchronously and attaches the verdict to the draft. The create-time verdict is UX — it lets an agent (or a human approver) see the likely outcome before clicking send. `rl.drafts.send()` **re-runs the scanner authoritatively** against the mailbox's current policy, so a stale cached verdict cannot slip through.
|
|
@@ -167,12 +199,12 @@ if draft["worst_decision"] == "allow":
|
|
|
167
199
|
print(f"Sent {result['message_id']}")
|
|
168
200
|
```
|
|
169
201
|
|
|
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/
|
|
202
|
+
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", "review_causes"?, "agent_instructions"}` or `None`) is the policy/human-review reason plus held-send guidance, non-None on held sends — a typed policy cause (first-contact/send-window/Supervised), a policy/human-review hold that changed the scanner's decision, or a genuine scan-explained hold such as a real scanner quarantine (`trigger_source`: `mailbox_policy` | `scanner` | `both`); it stays `None` on `sent`/terminal outcomes and normally on retryable infrastructure holds — a typed policy cause still attaches one (branch on `email_effect["effect_status"]` for those). `review_causes` is the typed hold-cause discriminator (`content_warning` | `first_contact` | `send_window` | `mailbox_policy`) driving a Supervised (`risky_only`) hold or a send-window promotion — a `NotRequired` key, absent on holds this dashboard policy builder didn't cause-type.
|
|
171
203
|
|
|
172
204
|
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.)
|
|
173
205
|
|
|
174
206
|
The send endpoint raises `ReplyLayerError` with distinct `.code` values on 409:
|
|
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/
|
|
207
|
+
- `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/human-review decision drove the hold, `hold_context`.
|
|
176
208
|
- `DRAFT_ALREADY_SENT` — the draft was already sent (race or retry after success).
|
|
177
209
|
|
|
178
210
|
```python
|
|
@@ -341,10 +373,39 @@ rl.mailboxes.update(
|
|
|
341
373
|
)
|
|
342
374
|
```
|
|
343
375
|
|
|
344
|
-
Supported actions are `"allow"`, `"allow_with_warning"`, `"review"`, `"quarantine"`, and `"block"`. `"review"` routes matching sends to
|
|
376
|
+
Supported actions are `"allow"`, `"allow_with_warning"`, `"review"`, `"quarantine"`, and `"block"`. `"review"` routes matching sends to your review queue (approve/deny) — available on every tier. Relaxing below platform defaults requires Pro+ (`pii_advanced_controls`); default or stricter values are accepted on every tier. Outbound PII scan results include `pii_type` (`"ssn"`, `"credit_card"`, or `"phone_number"`) so clients can inspect which type drove the action.
|
|
345
377
|
|
|
346
378
|
Approval notes are optional by default. Set `outbound_review_policy.approval_note` to `"required_for_sensitive_pii"` when approvers must add a note before sending SSN or credit-card review holds.
|
|
347
379
|
|
|
380
|
+
### Mailbox policy fields (dashboard policy builder)
|
|
381
|
+
|
|
382
|
+
`rl.mailboxes.update()` also accepts the per-mailbox fields the dashboard policy builder governs: `agent_authoring_mode` (`"send_and_draft"` | `"draft_only"` | `"read_only"`), `hitl_mode` (now widened to `"disabled"` | `"all_outbound"` | `"risky_only"`), `approval_expiry` (`"24h"` | `"72h"` | `"7d"` | `"never"`), and `send_window` (a dict binding AGENT-origin sends to a weekly window). `apply_policy_mode` applies one of the four named modes (`"read_only"` / `"draft_only"` / `"supervised"` / `"trusted"`) atomically and is mutually exclusive with those raw identity fields in the same call (the server returns `400 AMBIGUOUS_POLICY_MODE_APPLICATION`).
|
|
383
|
+
|
|
384
|
+
```python
|
|
385
|
+
# Apply a named mode — writes agent_authoring_mode/hitl_mode/agent_send_policy together.
|
|
386
|
+
rl.mailboxes.update(mailbox["id"], apply_policy_mode="supervised")
|
|
387
|
+
|
|
388
|
+
# Or set fields directly (not combined with apply_policy_mode in the same call).
|
|
389
|
+
rl.mailboxes.update(
|
|
390
|
+
mailbox["id"],
|
|
391
|
+
agent_authoring_mode="draft_only",
|
|
392
|
+
approval_expiry="72h",
|
|
393
|
+
send_window={
|
|
394
|
+
"timezone": "America/Chicago",
|
|
395
|
+
"days": ["mon", "tue", "wed", "thu", "fri"],
|
|
396
|
+
"start": "09:00",
|
|
397
|
+
"end": "18:00",
|
|
398
|
+
"outside_action": "require_approval",
|
|
399
|
+
},
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
# send_window omitted (default) leaves it unchanged; pass send_window=None
|
|
403
|
+
# explicitly to CLEAR it (always-open — a loosening).
|
|
404
|
+
rl.mailboxes.update(mailbox["id"], send_window=None)
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
**Direction gate.** Tightening (toward `read_only`/shorter expiry/narrower window) works with an admin API key. Any loosening requires a dashboard session + fresh re-auth — a bearer key gets `403 REAUTH_REQUIRES_SESSION`. `rl.policy.get_mailbox_policy(mailbox_id)` reads the derived `policy_mode`, `last_applied_policy_mode`, the calling key's `binding["permitted_verbs"]`, and `enforcement` — the live rollout-lever state (`{"risky_only": ..., "send_window": ...}`, each `"off"` | `"shadow"` | `"enforce"`). A stored `hitl_mode="risky_only"` or `send_window` only actually holds a send when the matching `enforcement` field reads `"enforce"`; `off`/`shadow` mean the posture is saved but not yet active. `rl.policy.get_overview()` carries the same `enforcement` block once, account-wide (the levers are env-global, not per-mailbox). `rl.policy.preview_mailbox_policy(mailbox_id, to=...)` is a side-effect-free dry-run of the gate stack for a sample send.
|
|
408
|
+
|
|
348
409
|
### Agent Attachment Access
|
|
349
410
|
|
|
350
411
|
Effective attachment exposure now comes from the mailbox policy surface (`attachment_exposure_mode` plus `attachment_allowed_file_families`), not from the legacy `attachment_access_enabled` boolean alone. Admin keys, pre-scoping keys, and dashboard sessions still bypass the agent mailbox-policy gate. Agent-key download requests without an explicit raw-download policy return 403 `ATTACHMENT_ACCESS_DISABLED` — surfaced as `ReplyLayerError` with `.code == "ATTACHMENT_ACCESS_DISABLED"`:
|
|
@@ -367,11 +428,11 @@ Images are a separately confirmed raw-download family. When `allowed_file_famili
|
|
|
367
428
|
|
|
368
429
|
Human dashboard sessions and admin/pre-scoping keys can download clean stored `metadata_only` attachments, including attachments held back from agent raw-download policy. Agent-role keys remain bound to the mailbox policy gate plus hard safety checks; all callers remain blocked by infected AV verdicts, non-terminal message states, missing stored bytes, and hard attachment blocks.
|
|
369
430
|
|
|
370
|
-
See
|
|
431
|
+
See the Mailboxes API reference at https://replylayer.ai/docs/api/mailboxes for the full contract and known limitations.
|
|
371
432
|
|
|
372
433
|
### Recipient allowlist (mailbox containment)
|
|
373
434
|
|
|
374
|
-
A mailbox is in `blocklist` mode by default — the pre-send gate rejects `suppressed_addresses` hits and allows everyone else. Switching to `allowlist` mode
|
|
435
|
+
A mailbox is in `blocklist` mode by default — the pre-send gate rejects `suppressed_addresses` hits and allows everyone else. Switching to `allowlist` mode contains **agent-origin** outbound to a pre-approved list (plus thread participants): a prompt-injected or compromised **agent key** cannot email outside the list. It is a containment boundary against a hijacked agent, not an all-origin lock — a human send (your dashboard session or an admin API key) is not restricted by the allowlist; only your do-not-contact (suppression) list binds a human send.
|
|
375
436
|
|
|
376
437
|
```python
|
|
377
438
|
# Populate the allowlist first. Admin-only — agent keys get 403 INSUFFICIENT_SCOPE.
|
|
@@ -393,7 +454,9 @@ rl.mailboxes.allowlist.delete(mailbox["id"], "partner@corp.com", force_empty=Tru
|
|
|
393
454
|
|
|
394
455
|
A send/reply/draft-send to a recipient on your do-not-contact (suppression) list raises `ReplyLayerError` with `.code == "RECIPIENT_SUPPRESSED"` (HTTP 403, `details["reason"] == "suppressed"`). This is terminal — escalate, don't retry; remove the suppression or send to a different recipient.
|
|
395
456
|
|
|
396
|
-
|
|
457
|
+
The same `RECIPIENT_SUPPRESSED` code also fires for a **platform-scoped** cross-account hard-bounce hit — an address that hard-bounced somewhere on the platform (not necessarily on your account), which ReplyLayer refuses on every ReplyLayer-managed sending domain to protect the shared reputation every customer rides on (never enforced on a delegated/BYOD or self-hosted domain). `details["scope"] == "platform"` distinguishes it from your own list (which omits `scope`); this variant isn't in `GET /v1/suppressions` and can't be removed via the SDK — it's an operator-only override.
|
|
458
|
+
|
|
459
|
+
Allowlist mutations are admin-only — granting mutation to an LLM defeats the agent-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`.
|
|
397
460
|
|
|
398
461
|
### Domain entries
|
|
399
462
|
|
|
@@ -446,9 +509,42 @@ Webhook deliveries are deduped server-side to at most one per `(account, mailbox
|
|
|
446
509
|
|
|
447
510
|
The MCP tool `list_allowlist_blocked_attempts` exposes the same view to agents — read-only by design. There is no dismiss-attempt tool (the containment boundary would be moot if an agent could clear its own rejection history).
|
|
448
511
|
|
|
512
|
+
## Recipient verification (send-path safety checks)
|
|
513
|
+
|
|
514
|
+
`POST /v1/messages/send`, `.../reply`, and `POST /v1/drafts/:id/send` run a permissive, fail-open recipient-quality check before dispatch. Only a **confirmed** violation rejects the send — an infrastructure hiccup (DNS blip, verification service outage) never blocks it. A reply or thread continuation is exempt (the recipient is a proven correspondent). Five `ReplyLayerError` codes, all HTTP 422:
|
|
515
|
+
|
|
516
|
+
| Code | Meaning |
|
|
517
|
+
|---|---|
|
|
518
|
+
| `RECIPIENT_ADDRESS_INVALID` | The address fails a strict syntax check beyond the basic email-format validation. |
|
|
519
|
+
| `RECIPIENT_DOMAIN_TYPO_SUSPECTED` | The domain looks like a single-character typo of a common consumer mail provider (e.g. `gmial.com`). The suggested domain rides in the exception message. |
|
|
520
|
+
| `RECIPIENT_ROLE_ADDRESS` | The local part is a structural role/distribution mailbox (`noreply@`, `no-reply@`, etc. by default), not an individual inbox. |
|
|
521
|
+
| `RECIPIENT_DISPOSABLE_ADDRESS` | The domain is a known disposable/temporary email provider. |
|
|
522
|
+
| `RECIPIENT_UNDELIVERABLE` | The domain has no mail servers (no MX or A record) — mail to it would hard-bounce. |
|
|
523
|
+
|
|
524
|
+
```python
|
|
525
|
+
from replylayer import ValidationError
|
|
526
|
+
|
|
527
|
+
try:
|
|
528
|
+
rl.messages.send(from_mailbox="support", to="noreply@example.com", subject="hi", body="x")
|
|
529
|
+
except ValidationError as err:
|
|
530
|
+
if err.code == "RECIPIENT_ROLE_ADDRESS":
|
|
531
|
+
print("That looks like a role mailbox, not a person — double-check the recipient.")
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
## Mailbox creation & sending-domain provisioning
|
|
535
|
+
|
|
536
|
+
On paid accounts (once the per-account sending-domain estate is enabled), `rl.mailboxes.create(...)` can race the account's sending-domain setup. There are no new SDK methods — two error codes flow through the existing error envelope:
|
|
537
|
+
|
|
538
|
+
| Code | HTTP | Meaning |
|
|
539
|
+
|---|---|---|
|
|
540
|
+
| `DOMAIN_PROVISIONING_PENDING` | 409 | The account's sending domain is still being set up (or a domain change is in flight). Retryable — `err.details["retry_after"]` carries the suggested seconds. Poll `rl.domains.list()` for the platform row's `verification_status`. |
|
|
541
|
+
| `DOMAIN_PROVISIONING_FAILED` | 409 | Sending-domain setup failed. Not retryable from the client — contact support. |
|
|
542
|
+
|
|
543
|
+
The SDK deliberately does **not** auto-retry; retry loops live in the CLI (`rly mailbox create` waits up to 60s) and the dashboard.
|
|
544
|
+
|
|
449
545
|
## Malicious link scanning (URL reputation)
|
|
450
546
|
|
|
451
|
-
Malicious link scanning checks inbound links against Google Web Risk (only SHA-256 hash-prefixes are sent — full URLs never leave the platform).
|
|
547
|
+
Malicious link scanning checks inbound links against Google Web Risk (only SHA-256 hash-prefixes are sent — full URLs never leave the platform). New accounts have it enabled by default at signup (disclosed in Privacy Policy §7a; per-mailbox opt-out via the mailbox scanner policy); older accounts opt in per account. Enabling it is an **admin action** (it turns on an account-wide sub-processor data flow), so agent-scoped keys can read status but not enable it (they raise `ForbiddenError`).
|
|
452
548
|
|
|
453
549
|
Pass the version you are acknowledging explicitly; the SDK does not auto-fetch it, so the consent is recorded against a version your code chose:
|
|
454
550
|
|
|
@@ -461,7 +557,7 @@ if not status["active"] and status["privacy_ok"]:
|
|
|
461
557
|
# res["url_reputation"]["active"] is True; res["disclosure"]["notice"] / ["advisory_url"]
|
|
462
558
|
```
|
|
463
559
|
|
|
464
|
-
If `privacy_ok` is `False` the account's
|
|
560
|
+
If `privacy_ok` is `False` the account's acknowledged privacy policy version predates the disclosed sub-processor — review and acknowledge 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(...)`).
|
|
465
561
|
|
|
466
562
|
## Trusted instruction sources
|
|
467
563
|
|
|
@@ -480,7 +576,7 @@ if ctx and ctx.get("instruction_trust"):
|
|
|
480
576
|
# content-safety judgment.
|
|
481
577
|
print(ctx["guidance"])
|
|
482
578
|
print(ctx["instruction_trust"])
|
|
483
|
-
# {"version": "v1", "match": "address", "verified_domain": ..., "verdict": "verified_aligned", "provenance": "
|
|
579
|
+
# {"version": "v1", "match": "address", "verified_domain": ..., "verdict": "verified_aligned", "provenance": "managed"}
|
|
484
580
|
```
|
|
485
581
|
|
|
486
582
|
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.
|
|
@@ -529,7 +625,7 @@ Error classes: `ReplyLayerError` (base), `AuthenticationError` (401), `Forbidden
|
|
|
529
625
|
|
|
530
626
|
## Webhook signature verification
|
|
531
627
|
|
|
532
|
-
> For a full integration guide (event catalog, retry behavior, idempotency, security, troubleshooting), see
|
|
628
|
+
> For a full integration guide (event catalog, retry behavior, idempotency, security, troubleshooting), see https://replylayer.ai/docs/webhooks.
|
|
533
629
|
|
|
534
630
|
```python
|
|
535
631
|
from replylayer import verify_webhook_signature
|
|
@@ -90,6 +90,9 @@ from .types import (
|
|
|
90
90
|
MessageReviewQueuedWebhookPayload,
|
|
91
91
|
MessageReviewApprovedWebhookPayload,
|
|
92
92
|
MessageReviewDeniedWebhookPayload,
|
|
93
|
+
# Policy builder slice 3b / §7 — expiry + release webhook payloads.
|
|
94
|
+
MessageReviewExpiredWebhookPayload,
|
|
95
|
+
MessageReleasedWebhookPayload,
|
|
93
96
|
# PR 7 — scanner-emit HITL review surface.
|
|
94
97
|
ReviewQueueTriggerSource,
|
|
95
98
|
# PR 9 — outbound PII safety tuning.
|
|
@@ -99,9 +102,25 @@ from .types import (
|
|
|
99
102
|
OutboundReviewApprovalNotePolicy,
|
|
100
103
|
OutboundReviewPolicy,
|
|
101
104
|
ScannerPolicy,
|
|
105
|
+
# First-party simulator MVP (plans/replylayer-simulator-mvp.md).
|
|
106
|
+
InjectSimulatorInboundRequest,
|
|
107
|
+
InjectSimulatorInboundResponse,
|
|
108
|
+
# Dashboard policy builder (plans/dashboard-policy-builder-mvp-2026-07-07.md §6.1).
|
|
109
|
+
PolicyMode,
|
|
110
|
+
DefaultPolicyMode,
|
|
111
|
+
AgentAuthoringMode,
|
|
112
|
+
MailboxHitlMode,
|
|
113
|
+
ApprovalExpiry,
|
|
114
|
+
AgentAuthoringVerb,
|
|
115
|
+
SendWindow,
|
|
116
|
+
PolicyEnforcement,
|
|
117
|
+
MailboxPolicy,
|
|
118
|
+
AccountPolicy,
|
|
119
|
+
PolicyOverview,
|
|
120
|
+
PolicyPreviewResult,
|
|
102
121
|
)
|
|
103
122
|
|
|
104
|
-
__version__ = "0.
|
|
123
|
+
__version__ = "0.26.0"
|
|
105
124
|
|
|
106
125
|
__all__ = [
|
|
107
126
|
# Message delete response (0.20.0).
|
|
@@ -188,6 +207,8 @@ __all__ = [
|
|
|
188
207
|
"MessageReviewQueuedWebhookPayload",
|
|
189
208
|
"MessageReviewApprovedWebhookPayload",
|
|
190
209
|
"MessageReviewDeniedWebhookPayload",
|
|
210
|
+
"MessageReviewExpiredWebhookPayload",
|
|
211
|
+
"MessageReleasedWebhookPayload",
|
|
191
212
|
# PR 7 — scanner-emit HITL.
|
|
192
213
|
"ReviewQueueTriggerSource",
|
|
193
214
|
# PR 9 — outbound PII safety tuning.
|
|
@@ -197,5 +218,21 @@ __all__ = [
|
|
|
197
218
|
"OutboundReviewApprovalNotePolicy",
|
|
198
219
|
"OutboundReviewPolicy",
|
|
199
220
|
"ScannerPolicy",
|
|
221
|
+
# First-party simulator MVP.
|
|
222
|
+
"InjectSimulatorInboundRequest",
|
|
223
|
+
"InjectSimulatorInboundResponse",
|
|
224
|
+
# Dashboard policy builder (§6.1).
|
|
225
|
+
"PolicyMode",
|
|
226
|
+
"DefaultPolicyMode",
|
|
227
|
+
"AgentAuthoringMode",
|
|
228
|
+
"MailboxHitlMode",
|
|
229
|
+
"ApprovalExpiry",
|
|
230
|
+
"AgentAuthoringVerb",
|
|
231
|
+
"SendWindow",
|
|
232
|
+
"PolicyEnforcement",
|
|
233
|
+
"MailboxPolicy",
|
|
234
|
+
"AccountPolicy",
|
|
235
|
+
"PolicyOverview",
|
|
236
|
+
"PolicyPreviewResult",
|
|
200
237
|
"__version__",
|
|
201
238
|
]
|
|
@@ -22,6 +22,8 @@ from .resources.account import SyncAccount, AsyncAccount
|
|
|
22
22
|
from .resources.health import SyncHealth, AsyncHealth
|
|
23
23
|
from .resources.domains import SyncDomains, AsyncDomains
|
|
24
24
|
from .resources.legal_holds import SyncLegalHolds, AsyncLegalHolds
|
|
25
|
+
from .resources.simulator import SyncSimulator, AsyncSimulator
|
|
26
|
+
from .resources.policy import SyncPolicy, AsyncPolicy
|
|
25
27
|
|
|
26
28
|
_DEFAULT_BASE_URL = "https://api.replylayer.ai"
|
|
27
29
|
|
|
@@ -74,6 +76,8 @@ class ReplyLayer:
|
|
|
74
76
|
self.account = SyncAccount(self._http)
|
|
75
77
|
self.health = SyncHealth(self._http)
|
|
76
78
|
self.legal_holds = SyncLegalHolds(self._http)
|
|
79
|
+
self.simulator = SyncSimulator(self._http)
|
|
80
|
+
self.policy = SyncPolicy(self._http)
|
|
77
81
|
|
|
78
82
|
def close(self) -> None:
|
|
79
83
|
self._http.close()
|
|
@@ -128,6 +132,8 @@ class AsyncReplyLayer:
|
|
|
128
132
|
self.account = AsyncAccount(self._http)
|
|
129
133
|
self.health = AsyncHealth(self._http)
|
|
130
134
|
self.legal_holds = AsyncLegalHolds(self._http)
|
|
135
|
+
self.simulator = AsyncSimulator(self._http)
|
|
136
|
+
self.policy = AsyncPolicy(self._http)
|
|
131
137
|
|
|
132
138
|
async def aclose(self) -> None:
|
|
133
139
|
await self._http.aclose()
|
|
@@ -23,6 +23,14 @@ class SyncApiKeys:
|
|
|
23
23
|
query = {"include_revoked": "true"} if include_revoked else None
|
|
24
24
|
return self._http.request("GET", "/v1/accounts/api-keys", query=query)
|
|
25
25
|
|
|
26
|
+
def update(self, id: str, *, mailbox_ids: list[str]) -> dict[str, Any]:
|
|
27
|
+
"""Update an agent key's mailbox bindings in place (full replace). The
|
|
28
|
+
key's secret and per-key capabilities are untouched; the change takes
|
|
29
|
+
effect on the key's next request. Admin-only; agent keys cannot re-scope."""
|
|
30
|
+
return self._http.request(
|
|
31
|
+
"PATCH", f"/v1/accounts/api-keys/{id}", body={"mailbox_ids": mailbox_ids}
|
|
32
|
+
)
|
|
33
|
+
|
|
26
34
|
def revoke(self, id: str) -> dict[str, Any]:
|
|
27
35
|
return self._http.request("DELETE", f"/v1/accounts/api-keys/{id}")
|
|
28
36
|
|
|
@@ -50,6 +58,14 @@ class AsyncApiKeys:
|
|
|
50
58
|
query = {"include_revoked": "true"} if include_revoked else None
|
|
51
59
|
return await self._http.request("GET", "/v1/accounts/api-keys", query=query)
|
|
52
60
|
|
|
61
|
+
async def update(self, id: str, *, mailbox_ids: list[str]) -> dict[str, Any]:
|
|
62
|
+
"""Update an agent key's mailbox bindings in place (full replace). The
|
|
63
|
+
key's secret and per-key capabilities are untouched; the change takes
|
|
64
|
+
effect on the key's next request. Admin-only; agent keys cannot re-scope."""
|
|
65
|
+
return await self._http.request(
|
|
66
|
+
"PATCH", f"/v1/accounts/api-keys/{id}", body={"mailbox_ids": mailbox_ids}
|
|
67
|
+
)
|
|
68
|
+
|
|
53
69
|
async def revoke(self, id: str) -> dict[str, Any]:
|
|
54
70
|
return await self._http.request("DELETE", f"/v1/accounts/api-keys/{id}")
|
|
55
71
|
|
|
@@ -209,7 +209,12 @@ class SyncDrafts:
|
|
|
209
209
|
otherwise ``SendMessageResponse``. Attachment-bearing drafts fail
|
|
210
210
|
closed on the async path (``400 ATTACHMENTS_REQUIRE_SYNC_SEND``).
|
|
211
211
|
Poll ``messages.get(message_id)`` until ``state`` is terminal to
|
|
212
|
-
observe the final outcome.
|
|
212
|
+
observe the final outcome. On a sandbox account an
|
|
213
|
+
``async_dispatch=True`` send can also raise a synchronous 409
|
|
214
|
+
``DRAFT_REJECTED_BY_RESCAN`` *before* the 202 — the verified-recipients
|
|
215
|
+
gate (``recipient-check``) runs at the enqueue preflight, so an
|
|
216
|
+
unverified recipient is rejected up front, exactly as on the
|
|
217
|
+
synchronous path.
|
|
213
218
|
|
|
214
219
|
Sandbox accounts are subject to a 250-cumulative-send trial
|
|
215
220
|
budget. Once exhausted the API returns 403 with
|
|
@@ -359,7 +364,10 @@ class AsyncDrafts:
|
|
|
359
364
|
hint. The hint is advisory — the server returns a 202 ``AsyncSendAck``
|
|
360
365
|
only when ``OUTBOUND_ASYNC_DISPATCH_ENABLED`` is on; otherwise it
|
|
361
366
|
ignores the hint and returns a normal ``SendMessageResponse``. Always
|
|
362
|
-
branch on ``status == "queued_for_dispatch"`` to distinguish the two.
|
|
367
|
+
branch on ``status == "queued_for_dispatch"`` to distinguish the two. A
|
|
368
|
+
sandbox unverified-recipient send can also raise a synchronous 409
|
|
369
|
+
``DRAFT_REJECTED_BY_RESCAN`` before the 202 (the ``recipient-check``
|
|
370
|
+
enqueue preflight).
|
|
363
371
|
"""
|
|
364
372
|
extra_headers: dict[str, str] | None = {"Prefer": "respond-async"} if async_dispatch else None
|
|
365
373
|
return await self._http.request("POST", f"/v1/drafts/{id}/send", body={}, extra_headers=extra_headers)
|