replylayer 0.23.0__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.
Files changed (48) hide show
  1. {replylayer-0.23.0 → replylayer-0.26.0}/.gitignore +5 -1
  2. {replylayer-0.23.0 → replylayer-0.26.0}/PKG-INFO +102 -6
  3. {replylayer-0.23.0 → replylayer-0.26.0}/README.md +101 -5
  4. {replylayer-0.23.0 → replylayer-0.26.0}/pyproject.toml +1 -1
  5. {replylayer-0.23.0 → replylayer-0.26.0}/replylayer/__init__.py +38 -1
  6. {replylayer-0.23.0 → replylayer-0.26.0}/replylayer/_client.py +6 -0
  7. {replylayer-0.23.0 → replylayer-0.26.0}/replylayer/_http.py +1 -1
  8. {replylayer-0.23.0 → replylayer-0.26.0}/replylayer/resources/api_keys.py +16 -0
  9. {replylayer-0.23.0 → replylayer-0.26.0}/replylayer/resources/drafts.py +10 -2
  10. {replylayer-0.23.0 → replylayer-0.26.0}/replylayer/resources/mailboxes.py +102 -0
  11. replylayer-0.26.0/replylayer/resources/policy.py +161 -0
  12. replylayer-0.26.0/replylayer/resources/simulator.py +39 -0
  13. {replylayer-0.23.0 → replylayer-0.26.0}/replylayer/types.py +298 -7
  14. {replylayer-0.23.0 → replylayer-0.26.0}/tests/test_async.py +48 -0
  15. {replylayer-0.23.0 → replylayer-0.26.0}/tests/test_client.py +1 -0
  16. {replylayer-0.23.0 → replylayer-0.26.0}/tests/test_hitl_review_types.py +24 -0
  17. {replylayer-0.23.0 → replylayer-0.26.0}/tests/test_resources.py +139 -0
  18. {replylayer-0.23.0 → replylayer-0.26.0}/uv.lock +4 -4
  19. {replylayer-0.23.0 → replylayer-0.26.0}/replylayer/__main__.py +0 -0
  20. {replylayer-0.23.0 → replylayer-0.26.0}/replylayer/_pagination.py +0 -0
  21. {replylayer-0.23.0 → replylayer-0.26.0}/replylayer/errors.py +0 -0
  22. {replylayer-0.23.0 → replylayer-0.26.0}/replylayer/py.typed +0 -0
  23. {replylayer-0.23.0 → replylayer-0.26.0}/replylayer/resources/__init__.py +0 -0
  24. {replylayer-0.23.0 → replylayer-0.26.0}/replylayer/resources/account.py +0 -0
  25. {replylayer-0.23.0 → replylayer-0.26.0}/replylayer/resources/attachments.py +0 -0
  26. {replylayer-0.23.0 → replylayer-0.26.0}/replylayer/resources/domains.py +0 -0
  27. {replylayer-0.23.0 → replylayer-0.26.0}/replylayer/resources/health.py +0 -0
  28. {replylayer-0.23.0 → replylayer-0.26.0}/replylayer/resources/inbound_blocklist.py +0 -0
  29. {replylayer-0.23.0 → replylayer-0.26.0}/replylayer/resources/legal_holds.py +0 -0
  30. {replylayer-0.23.0 → replylayer-0.26.0}/replylayer/resources/messages.py +0 -0
  31. {replylayer-0.23.0 → replylayer-0.26.0}/replylayer/resources/recipients.py +0 -0
  32. {replylayer-0.23.0 → replylayer-0.26.0}/replylayer/resources/suppressions.py +0 -0
  33. {replylayer-0.23.0 → replylayer-0.26.0}/replylayer/resources/threads.py +0 -0
  34. {replylayer-0.23.0 → replylayer-0.26.0}/replylayer/resources/webhooks.py +0 -0
  35. {replylayer-0.23.0 → replylayer-0.26.0}/tests/__init__.py +0 -0
  36. {replylayer-0.23.0 → replylayer-0.26.0}/tests/test_attachments.py +0 -0
  37. {replylayer-0.23.0 → replylayer-0.26.0}/tests/test_domains.py +0 -0
  38. {replylayer-0.23.0 → replylayer-0.26.0}/tests/test_drafts.py +0 -0
  39. {replylayer-0.23.0 → replylayer-0.26.0}/tests/test_governed_email_effect.py +0 -0
  40. {replylayer-0.23.0 → replylayer-0.26.0}/tests/test_http.py +0 -0
  41. {replylayer-0.23.0 → replylayer-0.26.0}/tests/test_instruction_trust.py +0 -0
  42. {replylayer-0.23.0 → replylayer-0.26.0}/tests/test_messages_idempotency.py +0 -0
  43. {replylayer-0.23.0 → replylayer-0.26.0}/tests/test_readme_resource_parity.py +0 -0
  44. {replylayer-0.23.0 → replylayer-0.26.0}/tests/test_threads.py +0 -0
  45. {replylayer-0.23.0 → replylayer-0.26.0}/tests/test_version.py +0 -0
  46. {replylayer-0.23.0 → replylayer-0.26.0}/tests/test_web_risk_types.py +0 -0
  47. {replylayer-0.23.0 → replylayer-0.26.0}/tests/test_webhooks.py +0 -0
  48. {replylayer-0.23.0 → 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.23.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,7 +219,7 @@ 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/human-review reason, non-null only when the delivery `status` diverges from `scan["verdict"]` because of a policy/human-review 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`).
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
 
@@ -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 Pending approval; enabling it requires both Pro+ outbound PII controls and the review queue feature. 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.
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"`:
@@ -413,6 +474,8 @@ 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
 
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
+
416
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
@@ -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). It is off by default and 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`).
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 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(...)`).
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
 
@@ -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,7 +199,7 @@ 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/human-review reason, non-null only when the delivery `status` diverges from `scan["verdict"]` because of a policy/human-review 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`).
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
 
@@ -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 Pending approval; enabling it requires both Pro+ outbound PII controls and the review queue feature. 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.
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"`:
@@ -393,6 +454,8 @@ 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
 
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
+
396
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
@@ -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). It is off by default and 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`).
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 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(...)`).
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "replylayer"
7
- version = "0.23.0"
7
+ version = "0.26.0"
8
8
  description = "Official Python SDK for ReplyLayer — email for AI agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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.23.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()
@@ -9,7 +9,7 @@ import httpx
9
9
 
10
10
  from .errors import ReplyLayerError, error_from_response
11
11
 
12
- _VERSION = "0.23.0"
12
+ _VERSION = "0.26.0"
13
13
  _USER_AGENT = f"replylayer-sdk-py/{_VERSION}"
14
14
  _PROTECTED_HEADER_KEYS = frozenset({"authorization", "content-type", "user-agent"})
15
15
 
@@ -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)