replylayer 0.23.0__tar.gz → 0.27.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.27.0}/.gitignore +5 -1
  2. {replylayer-0.23.0 → replylayer-0.27.0}/PKG-INFO +131 -7
  3. {replylayer-0.23.0 → replylayer-0.27.0}/README.md +129 -5
  4. {replylayer-0.23.0 → replylayer-0.27.0}/pyproject.toml +1 -1
  5. {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/__init__.py +42 -1
  6. {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/_client.py +6 -0
  7. {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/_http.py +1 -1
  8. {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/api_keys.py +16 -0
  9. {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/drafts.py +10 -2
  10. {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/mailboxes.py +102 -0
  11. replylayer-0.27.0/replylayer/resources/policy.py +161 -0
  12. replylayer-0.27.0/replylayer/resources/simulator.py +39 -0
  13. {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/webhooks.py +78 -7
  14. {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/types.py +321 -7
  15. {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_async.py +48 -0
  16. {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_client.py +1 -0
  17. {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_hitl_review_types.py +24 -0
  18. {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_resources.py +139 -0
  19. {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_webhooks.py +149 -0
  20. {replylayer-0.23.0 → replylayer-0.27.0}/uv.lock +4 -4
  21. {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/__main__.py +0 -0
  22. {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/_pagination.py +0 -0
  23. {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/errors.py +0 -0
  24. {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/py.typed +0 -0
  25. {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/__init__.py +0 -0
  26. {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/account.py +0 -0
  27. {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/attachments.py +0 -0
  28. {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/domains.py +0 -0
  29. {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/health.py +0 -0
  30. {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/inbound_blocklist.py +0 -0
  31. {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/legal_holds.py +0 -0
  32. {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/messages.py +0 -0
  33. {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/recipients.py +0 -0
  34. {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/suppressions.py +0 -0
  35. {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/threads.py +0 -0
  36. {replylayer-0.23.0 → replylayer-0.27.0}/tests/__init__.py +0 -0
  37. {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_attachments.py +0 -0
  38. {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_domains.py +0 -0
  39. {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_drafts.py +0 -0
  40. {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_governed_email_effect.py +0 -0
  41. {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_http.py +0 -0
  42. {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_instruction_trust.py +0 -0
  43. {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_messages_idempotency.py +0 -0
  44. {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_readme_resource_parity.py +0 -0
  45. {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_threads.py +0 -0
  46. {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_version.py +0 -0
  47. {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_web_risk_types.py +0 -0
  48. {replylayer-0.23.0 → replylayer-0.27.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
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: replylayer
3
- Version: 0.23.0
3
+ Version: 0.27.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
 
@@ -244,6 +276,34 @@ result = rl.messages.send(
244
276
 
245
277
  A handle is **consumed once** at send and is single-mailbox-scoped (upload to the same mailbox you send from). Unconsumed handles expire after 24h; delete one early with `rl.attachments.delete_upload(handle["id"])`. Limits: 10 MB/file, 10 attachments and 15 MB total per message. Image attachments require a separate one-time image-risk disclaimer on the mailbox (`OUTBOUND_IMAGE_DISCLAIMER_REQUIRED`). Drafts hold handles and consume them at dispatch; `rl.drafts.update(draft_id, attachment_ids=None)` clears a draft's attachments. Attachment bytes are stored with provider-managed encryption-at-rest and transmitted over TLS — this is not end-to-end / zero-access encryption (the platform scans attachment content).
246
278
 
279
+ ## Webhook custom request headers
280
+
281
+ For a receiver that authenticates by a fixed header rather than by ReplyLayer's HMAC, `webhooks.create` and `webhooks.update` accept `request_headers` — up to 8 static headers added to every delivery attempt for that webhook (first attempt, every retry, `test()`, and a manual `retry_delivery`).
282
+
283
+ ```python
284
+ wh = rl.webhooks.create(
285
+ url="https://your-server.com/hooks/replylayer",
286
+ enabled_events=["message.received"],
287
+ request_headers={"Authorization": "Bearer your-receiver-token"},
288
+ )
289
+
290
+ wh["request_header_names"] # ['authorization'] — names only, lower-cased + sorted
291
+ ```
292
+
293
+ **Values are write-only.** They are encrypted at rest and returned by no read path: every webhook response carries `request_header_names` (`[]` when none) and never a value. Because of that, `update` is three-way and there is no per-header merge:
294
+
295
+ ```python
296
+ rl.webhooks.update(wh["id"], request_headers={"Authorization": "Bearer rotated"}) # replaces the whole map
297
+ rl.webhooks.update(wh["id"], request_headers=None) # clears every header
298
+ rl.webhooks.update(wh["id"], enabled=False) # leaves headers untouched
299
+ ```
300
+
301
+ `None` means "clear" here, so omitting the argument entirely is the only way to leave the configured headers alone.
302
+
303
+ Names match `^[A-Za-z0-9-]{1,64}$` and are compared case-insensitively; values are 1–1024 printable-ASCII characters. Reserved names — `content-type`, `content-length`, `host`, `user-agent`, `x-replylayer-signature`, `x-webhook-timestamp`, `x-webhook-signature-v2`, `transfer-encoding`, `connection`, `expect`, `te`, `upgrade`, `keep-alive`, `trailer`, and anything starting with `proxy-` — are rejected. The SDK does not pre-validate: rejections arrive as `ReplyLayerError` with `.code` set to `WEBHOOK_HEADER_RESERVED`, `WEBHOOK_HEADER_DUPLICATE`, `WEBHOOK_HEADER_INVALID`, or `VALIDATION_ERROR`.
304
+
305
+ Deliveries for a webhook with custom headers store no response body (`response_preview` is always `None`), so a receiver that echoes the request cannot leak a configured credential back into the delivery history.
306
+
247
307
  ## Delivery history & manual retry
248
308
 
249
309
  `rl.webhooks.list_deliveries(id, limit=..., before_at=..., before_id=...)` returns the most recent delivery attempts for a webhook with tuple-cursor keyset pagination. `before_at` and `before_id` must be provided together — the SDK omits the cursor entirely if only one is given.
@@ -361,10 +421,39 @@ rl.mailboxes.update(
361
421
  )
362
422
  ```
363
423
 
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.
424
+ 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
425
 
366
426
  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
427
 
428
+ ### Mailbox policy fields (dashboard policy builder)
429
+
430
+ `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`).
431
+
432
+ ```python
433
+ # Apply a named mode — writes agent_authoring_mode/hitl_mode/agent_send_policy together.
434
+ rl.mailboxes.update(mailbox["id"], apply_policy_mode="supervised")
435
+
436
+ # Or set fields directly (not combined with apply_policy_mode in the same call).
437
+ rl.mailboxes.update(
438
+ mailbox["id"],
439
+ agent_authoring_mode="draft_only",
440
+ approval_expiry="72h",
441
+ send_window={
442
+ "timezone": "America/Chicago",
443
+ "days": ["mon", "tue", "wed", "thu", "fri"],
444
+ "start": "09:00",
445
+ "end": "18:00",
446
+ "outside_action": "require_approval",
447
+ },
448
+ )
449
+
450
+ # send_window omitted (default) leaves it unchanged; pass send_window=None
451
+ # explicitly to CLEAR it (always-open — a loosening).
452
+ rl.mailboxes.update(mailbox["id"], send_window=None)
453
+ ```
454
+
455
+ **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.
456
+
368
457
  ### Agent Attachment Access
369
458
 
370
459
  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 +502,8 @@ rl.mailboxes.allowlist.delete(mailbox["id"], "partner@corp.com", force_empty=Tru
413
502
 
414
503
  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
504
 
505
+ 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.
506
+
416
507
  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
508
 
418
509
  ### Domain entries
@@ -466,9 +557,42 @@ Webhook deliveries are deduped server-side to at most one per `(account, mailbox
466
557
 
467
558
  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
559
 
560
+ ## Recipient verification (send-path safety checks)
561
+
562
+ `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:
563
+
564
+ | Code | Meaning |
565
+ |---|---|
566
+ | `RECIPIENT_ADDRESS_INVALID` | The address fails a strict syntax check beyond the basic email-format validation. |
567
+ | `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. |
568
+ | `RECIPIENT_ROLE_ADDRESS` | The local part is a structural role/distribution mailbox (`noreply@`, `no-reply@`, etc. by default), not an individual inbox. |
569
+ | `RECIPIENT_DISPOSABLE_ADDRESS` | The domain is a known disposable/temporary email provider. |
570
+ | `RECIPIENT_UNDELIVERABLE` | The domain has no mail servers (no MX or A record) — mail to it would hard-bounce. |
571
+
572
+ ```python
573
+ from replylayer import ValidationError
574
+
575
+ try:
576
+ rl.messages.send(from_mailbox="support", to="noreply@example.com", subject="hi", body="x")
577
+ except ValidationError as err:
578
+ if err.code == "RECIPIENT_ROLE_ADDRESS":
579
+ print("That looks like a role mailbox, not a person — double-check the recipient.")
580
+ ```
581
+
582
+ ## Mailbox creation & sending-domain provisioning
583
+
584
+ 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:
585
+
586
+ | Code | HTTP | Meaning |
587
+ |---|---|---|
588
+ | `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`. |
589
+ | `DOMAIN_PROVISIONING_FAILED` | 409 | Sending-domain setup failed. Not retryable from the client — contact support. |
590
+
591
+ The SDK deliberately does **not** auto-retry; retry loops live in the CLI (`rly mailbox create` waits up to 60s) and the dashboard.
592
+
469
593
  ## Malicious link scanning (URL reputation)
470
594
 
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`).
595
+ 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
596
 
473
597
  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
598
 
@@ -481,7 +605,7 @@ if not status["active"] and status["privacy_ok"]:
481
605
  # res["url_reputation"]["active"] is True; res["disclosure"]["notice"] / ["advisory_url"]
482
606
  ```
483
607
 
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(...)`).
608
+ 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
609
 
486
610
  ## Trusted instruction sources
487
611
 
@@ -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
 
@@ -224,6 +256,34 @@ result = rl.messages.send(
224
256
 
225
257
  A handle is **consumed once** at send and is single-mailbox-scoped (upload to the same mailbox you send from). Unconsumed handles expire after 24h; delete one early with `rl.attachments.delete_upload(handle["id"])`. Limits: 10 MB/file, 10 attachments and 15 MB total per message. Image attachments require a separate one-time image-risk disclaimer on the mailbox (`OUTBOUND_IMAGE_DISCLAIMER_REQUIRED`). Drafts hold handles and consume them at dispatch; `rl.drafts.update(draft_id, attachment_ids=None)` clears a draft's attachments. Attachment bytes are stored with provider-managed encryption-at-rest and transmitted over TLS — this is not end-to-end / zero-access encryption (the platform scans attachment content).
226
258
 
259
+ ## Webhook custom request headers
260
+
261
+ For a receiver that authenticates by a fixed header rather than by ReplyLayer's HMAC, `webhooks.create` and `webhooks.update` accept `request_headers` — up to 8 static headers added to every delivery attempt for that webhook (first attempt, every retry, `test()`, and a manual `retry_delivery`).
262
+
263
+ ```python
264
+ wh = rl.webhooks.create(
265
+ url="https://your-server.com/hooks/replylayer",
266
+ enabled_events=["message.received"],
267
+ request_headers={"Authorization": "Bearer your-receiver-token"},
268
+ )
269
+
270
+ wh["request_header_names"] # ['authorization'] — names only, lower-cased + sorted
271
+ ```
272
+
273
+ **Values are write-only.** They are encrypted at rest and returned by no read path: every webhook response carries `request_header_names` (`[]` when none) and never a value. Because of that, `update` is three-way and there is no per-header merge:
274
+
275
+ ```python
276
+ rl.webhooks.update(wh["id"], request_headers={"Authorization": "Bearer rotated"}) # replaces the whole map
277
+ rl.webhooks.update(wh["id"], request_headers=None) # clears every header
278
+ rl.webhooks.update(wh["id"], enabled=False) # leaves headers untouched
279
+ ```
280
+
281
+ `None` means "clear" here, so omitting the argument entirely is the only way to leave the configured headers alone.
282
+
283
+ Names match `^[A-Za-z0-9-]{1,64}$` and are compared case-insensitively; values are 1–1024 printable-ASCII characters. Reserved names — `content-type`, `content-length`, `host`, `user-agent`, `x-replylayer-signature`, `x-webhook-timestamp`, `x-webhook-signature-v2`, `transfer-encoding`, `connection`, `expect`, `te`, `upgrade`, `keep-alive`, `trailer`, and anything starting with `proxy-` — are rejected. The SDK does not pre-validate: rejections arrive as `ReplyLayerError` with `.code` set to `WEBHOOK_HEADER_RESERVED`, `WEBHOOK_HEADER_DUPLICATE`, `WEBHOOK_HEADER_INVALID`, or `VALIDATION_ERROR`.
284
+
285
+ Deliveries for a webhook with custom headers store no response body (`response_preview` is always `None`), so a receiver that echoes the request cannot leak a configured credential back into the delivery history.
286
+
227
287
  ## Delivery history & manual retry
228
288
 
229
289
  `rl.webhooks.list_deliveries(id, limit=..., before_at=..., before_id=...)` returns the most recent delivery attempts for a webhook with tuple-cursor keyset pagination. `before_at` and `before_id` must be provided together — the SDK omits the cursor entirely if only one is given.
@@ -341,10 +401,39 @@ rl.mailboxes.update(
341
401
  )
342
402
  ```
343
403
 
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.
404
+ 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
405
 
346
406
  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
407
 
408
+ ### Mailbox policy fields (dashboard policy builder)
409
+
410
+ `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`).
411
+
412
+ ```python
413
+ # Apply a named mode — writes agent_authoring_mode/hitl_mode/agent_send_policy together.
414
+ rl.mailboxes.update(mailbox["id"], apply_policy_mode="supervised")
415
+
416
+ # Or set fields directly (not combined with apply_policy_mode in the same call).
417
+ rl.mailboxes.update(
418
+ mailbox["id"],
419
+ agent_authoring_mode="draft_only",
420
+ approval_expiry="72h",
421
+ send_window={
422
+ "timezone": "America/Chicago",
423
+ "days": ["mon", "tue", "wed", "thu", "fri"],
424
+ "start": "09:00",
425
+ "end": "18:00",
426
+ "outside_action": "require_approval",
427
+ },
428
+ )
429
+
430
+ # send_window omitted (default) leaves it unchanged; pass send_window=None
431
+ # explicitly to CLEAR it (always-open — a loosening).
432
+ rl.mailboxes.update(mailbox["id"], send_window=None)
433
+ ```
434
+
435
+ **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.
436
+
348
437
  ### Agent Attachment Access
349
438
 
350
439
  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 +482,8 @@ rl.mailboxes.allowlist.delete(mailbox["id"], "partner@corp.com", force_empty=Tru
393
482
 
394
483
  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
484
 
485
+ 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.
486
+
396
487
  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
488
 
398
489
  ### Domain entries
@@ -446,9 +537,42 @@ Webhook deliveries are deduped server-side to at most one per `(account, mailbox
446
537
 
447
538
  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
539
 
540
+ ## Recipient verification (send-path safety checks)
541
+
542
+ `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:
543
+
544
+ | Code | Meaning |
545
+ |---|---|
546
+ | `RECIPIENT_ADDRESS_INVALID` | The address fails a strict syntax check beyond the basic email-format validation. |
547
+ | `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. |
548
+ | `RECIPIENT_ROLE_ADDRESS` | The local part is a structural role/distribution mailbox (`noreply@`, `no-reply@`, etc. by default), not an individual inbox. |
549
+ | `RECIPIENT_DISPOSABLE_ADDRESS` | The domain is a known disposable/temporary email provider. |
550
+ | `RECIPIENT_UNDELIVERABLE` | The domain has no mail servers (no MX or A record) — mail to it would hard-bounce. |
551
+
552
+ ```python
553
+ from replylayer import ValidationError
554
+
555
+ try:
556
+ rl.messages.send(from_mailbox="support", to="noreply@example.com", subject="hi", body="x")
557
+ except ValidationError as err:
558
+ if err.code == "RECIPIENT_ROLE_ADDRESS":
559
+ print("That looks like a role mailbox, not a person — double-check the recipient.")
560
+ ```
561
+
562
+ ## Mailbox creation & sending-domain provisioning
563
+
564
+ 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:
565
+
566
+ | Code | HTTP | Meaning |
567
+ |---|---|---|
568
+ | `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`. |
569
+ | `DOMAIN_PROVISIONING_FAILED` | 409 | Sending-domain setup failed. Not retryable from the client — contact support. |
570
+
571
+ The SDK deliberately does **not** auto-retry; retry loops live in the CLI (`rly mailbox create` waits up to 60s) and the dashboard.
572
+
449
573
  ## Malicious link scanning (URL reputation)
450
574
 
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`).
575
+ 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
576
 
453
577
  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
578
 
@@ -461,7 +585,7 @@ if not status["active"] and status["privacy_ok"]:
461
585
  # res["url_reputation"]["active"] is True; res["disclosure"]["notice"] / ["advisory_url"]
462
586
  ```
463
587
 
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(...)`).
588
+ 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
589
 
466
590
  ## Trusted instruction sources
467
591
 
@@ -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.27.0"
8
8
  description = "Official Python SDK for ReplyLayer — email for AI agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -29,6 +29,8 @@ from .types import (
29
29
  # WS6-SDK — async optimistic-ack (0.16.0).
30
30
  AsyncSendAck,
31
31
  WebhookEventType,
32
+ # Migration 134 — per-webhook custom request headers (write-only values).
33
+ WebhookRequestHeaders,
32
34
  WebhookSummary,
33
35
  WebhookDeliverySummary,
34
36
  WebhookDeliveryStatus,
@@ -90,6 +92,9 @@ from .types import (
90
92
  MessageReviewQueuedWebhookPayload,
91
93
  MessageReviewApprovedWebhookPayload,
92
94
  MessageReviewDeniedWebhookPayload,
95
+ # Policy builder slice 3b / §7 — expiry + release webhook payloads.
96
+ MessageReviewExpiredWebhookPayload,
97
+ MessageReleasedWebhookPayload,
93
98
  # PR 7 — scanner-emit HITL review surface.
94
99
  ReviewQueueTriggerSource,
95
100
  # PR 9 — outbound PII safety tuning.
@@ -99,9 +104,25 @@ from .types import (
99
104
  OutboundReviewApprovalNotePolicy,
100
105
  OutboundReviewPolicy,
101
106
  ScannerPolicy,
107
+ # First-party simulator MVP (plans/replylayer-simulator-mvp.md).
108
+ InjectSimulatorInboundRequest,
109
+ InjectSimulatorInboundResponse,
110
+ # Dashboard policy builder (plans/dashboard-policy-builder-mvp-2026-07-07.md §6.1).
111
+ PolicyMode,
112
+ DefaultPolicyMode,
113
+ AgentAuthoringMode,
114
+ MailboxHitlMode,
115
+ ApprovalExpiry,
116
+ AgentAuthoringVerb,
117
+ SendWindow,
118
+ PolicyEnforcement,
119
+ MailboxPolicy,
120
+ AccountPolicy,
121
+ PolicyOverview,
122
+ PolicyPreviewResult,
102
123
  )
103
124
 
104
- __version__ = "0.23.0"
125
+ __version__ = "0.27.0"
105
126
 
106
127
  __all__ = [
107
128
  # Message delete response (0.20.0).
@@ -136,6 +157,8 @@ __all__ = [
136
157
  "EmailEffect",
137
158
  "EffectStatus",
138
159
  "WebhookEventType",
160
+ # Migration 134 — per-webhook custom request headers (write-only values).
161
+ "WebhookRequestHeaders",
139
162
  "WebhookSummary",
140
163
  "WebhookDeliverySummary",
141
164
  "WebhookDeliveryStatus",
@@ -188,6 +211,8 @@ __all__ = [
188
211
  "MessageReviewQueuedWebhookPayload",
189
212
  "MessageReviewApprovedWebhookPayload",
190
213
  "MessageReviewDeniedWebhookPayload",
214
+ "MessageReviewExpiredWebhookPayload",
215
+ "MessageReleasedWebhookPayload",
191
216
  # PR 7 — scanner-emit HITL.
192
217
  "ReviewQueueTriggerSource",
193
218
  # PR 9 — outbound PII safety tuning.
@@ -197,5 +222,21 @@ __all__ = [
197
222
  "OutboundReviewApprovalNotePolicy",
198
223
  "OutboundReviewPolicy",
199
224
  "ScannerPolicy",
225
+ # First-party simulator MVP.
226
+ "InjectSimulatorInboundRequest",
227
+ "InjectSimulatorInboundResponse",
228
+ # Dashboard policy builder (§6.1).
229
+ "PolicyMode",
230
+ "DefaultPolicyMode",
231
+ "AgentAuthoringMode",
232
+ "MailboxHitlMode",
233
+ "ApprovalExpiry",
234
+ "AgentAuthoringVerb",
235
+ "SendWindow",
236
+ "PolicyEnforcement",
237
+ "MailboxPolicy",
238
+ "AccountPolicy",
239
+ "PolicyOverview",
240
+ "PolicyPreviewResult",
200
241
  "__version__",
201
242
  ]
@@ -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.27.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)