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.
- {replylayer-0.23.0 → replylayer-0.27.0}/.gitignore +5 -1
- {replylayer-0.23.0 → replylayer-0.27.0}/PKG-INFO +131 -7
- {replylayer-0.23.0 → replylayer-0.27.0}/README.md +129 -5
- {replylayer-0.23.0 → replylayer-0.27.0}/pyproject.toml +1 -1
- {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/__init__.py +42 -1
- {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/_client.py +6 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/_http.py +1 -1
- {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/api_keys.py +16 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/drafts.py +10 -2
- {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/mailboxes.py +102 -0
- replylayer-0.27.0/replylayer/resources/policy.py +161 -0
- replylayer-0.27.0/replylayer/resources/simulator.py +39 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/webhooks.py +78 -7
- {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/types.py +321 -7
- {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_async.py +48 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_client.py +1 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_hitl_review_types.py +24 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_resources.py +139 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_webhooks.py +149 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/uv.lock +4 -4
- {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/__main__.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/_pagination.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/errors.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/py.typed +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/__init__.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/account.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/attachments.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/domains.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/health.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/inbound_blocklist.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/legal_holds.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/messages.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/recipients.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/suppressions.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/replylayer/resources/threads.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/tests/__init__.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_attachments.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_domains.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_drafts.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_governed_email_effect.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_http.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_instruction_trust.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_messages_idempotency.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_readme_resource_parity.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_threads.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_version.py +0 -0
- {replylayer-0.23.0 → replylayer-0.27.0}/tests/test_web_risk_types.py +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: replylayer
|
|
3
|
-
Version: 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-
|
|
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
|
|
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).
|
|
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
|
|
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-
|
|
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
|
|
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).
|
|
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
|
|
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
|
|
|
@@ -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.
|
|
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()
|
|
@@ -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)
|