replylayer 0.22.0__tar.gz → 0.23.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 (46) hide show
  1. replylayer-0.22.0/README.md → replylayer-0.23.0/PKG-INFO +76 -19
  2. replylayer-0.22.0/PKG-INFO → replylayer-0.23.0/README.md +56 -36
  3. {replylayer-0.22.0 → replylayer-0.23.0}/pyproject.toml +6 -1
  4. {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/__init__.py +1 -1
  5. {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/_http.py +1 -1
  6. {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/errors.py +3 -4
  7. {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/mailboxes.py +17 -13
  8. {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/types.py +41 -24
  9. {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_drafts.py +5 -5
  10. {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_hitl_review_types.py +1 -1
  11. {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_instruction_trust.py +1 -1
  12. replylayer-0.23.0/tests/test_readme_resource_parity.py +80 -0
  13. {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_resources.py +30 -0
  14. {replylayer-0.22.0 → replylayer-0.23.0}/.gitignore +0 -0
  15. {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/__main__.py +0 -0
  16. {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/_client.py +0 -0
  17. {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/_pagination.py +0 -0
  18. {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/py.typed +0 -0
  19. {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/__init__.py +0 -0
  20. {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/account.py +0 -0
  21. {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/api_keys.py +0 -0
  22. {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/attachments.py +0 -0
  23. {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/domains.py +0 -0
  24. {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/drafts.py +0 -0
  25. {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/health.py +0 -0
  26. {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/inbound_blocklist.py +0 -0
  27. {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/legal_holds.py +0 -0
  28. {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/messages.py +0 -0
  29. {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/recipients.py +0 -0
  30. {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/suppressions.py +0 -0
  31. {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/threads.py +0 -0
  32. {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/webhooks.py +0 -0
  33. {replylayer-0.22.0 → replylayer-0.23.0}/tests/__init__.py +0 -0
  34. {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_async.py +0 -0
  35. {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_attachments.py +0 -0
  36. {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_client.py +0 -0
  37. {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_domains.py +0 -0
  38. {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_governed_email_effect.py +0 -0
  39. {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_http.py +0 -0
  40. {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_messages_idempotency.py +0 -0
  41. {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_threads.py +0 -0
  42. {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_version.py +0 -0
  43. {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_web_risk_types.py +0 -0
  44. {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_webhooks.py +0 -0
  45. {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_ws1_ws6.py +0 -0
  46. {replylayer-0.22.0 → replylayer-0.23.0}/uv.lock +0 -0
@@ -1,3 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: replylayer
3
+ Version: 0.23.0
4
+ Summary: Official Python SDK for ReplyLayer — email for AI agents
5
+ Project-URL: Homepage, https://replylayer.ai
6
+ Project-URL: Repository, https://github.com/replylayer/rly
7
+ Project-URL: Issues, https://github.com/replylayer/rly/issues
8
+ License-Expression: MIT
9
+ Keywords: agent,ai,email,mailbox,replylayer,sdk,webhook
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: httpx>=0.27
12
+ Requires-Dist: typing-extensions>=4.0
13
+ Provides-Extra: cli
14
+ Requires-Dist: rly>=0.6.3; extra == 'cli'
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
17
+ Requires-Dist: pytest>=8.0; extra == 'dev'
18
+ Requires-Dist: respx>=0.21; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
1
21
  # replylayer
2
22
 
3
23
  Official Python SDK for [ReplyLayer](https://replylayer.ai) — secure email for AI agents.
@@ -80,7 +100,8 @@ contract — read it before relying on retries:
80
100
  - **`5xx` is retried only on non-mutating (`GET`) requests.** A `5xx` on a
81
101
  `POST` / `PATCH` / `DELETE` is **not** retried — the request may have executed,
82
102
  so a retry risks a double-send (or, for `DELETE`, retrying a lost-but-applied
83
- delete into a confusing `404`).
103
+ delete into a confusing `404`). To retry a `send` / `reply` safely, pass an
104
+ idempotency key — see [Idempotent sends](#idempotent-sends).
84
105
  - **Multipart uploads are never retried** (a retry would re-send the body).
85
106
  - **Long `Retry-After` values block up to `max_retry_after_seconds`** (default
86
107
  ~67 minutes, sized to ride out hour-bucket rate limits for batch jobs). When a
@@ -95,21 +116,57 @@ contract — read it before relying on retries:
95
116
  may be a coroutine (it's awaited); a raising callback is swallowed so it can't
96
117
  break the retry.
97
118
 
119
+ ## Idempotent sends
120
+
121
+ Because a `5xx` on a send is **not** auto-retried (a blind retry risks a second
122
+ delivery + a second charge), the SDK gives you a way to retry it yourself
123
+ *safely*. `messages.send`, `messages.reply`, and scheduled sends via
124
+ `drafts.create` (with `send_at`) accept an `idempotency_key`: a network-retried
125
+ request carrying the same key produces **at most one** message and one charge —
126
+ the server replays the original outcome and returns the **same `message_id`**
127
+ instead of sending again.
128
+
129
+ ```python
130
+ import uuid
131
+
132
+ key = str(uuid.uuid4()) # stable per send intent — persist it with the job
133
+
134
+ # First call sends; a same-key retry replays the first result (no second send).
135
+ sent = rl.messages.send(
136
+ from_mailbox="support",
137
+ to="user@example.com",
138
+ subject="Hi",
139
+ body="Hello",
140
+ idempotency_key=key,
141
+ )
142
+ print(sent["message_id"]) # a same-key retry returns this SAME id
143
+ ```
144
+
145
+ The key travels as the `Idempotency-Key` request header and is permanent (no
146
+ expiry). A non-throwing probe, `rl.messages.get_idempotency_replay(key)`, reports
147
+ whether a key already produced a result, is still in flight, or is a miss — call
148
+ it before a retry whose local inputs (a staged attachment, the original message)
149
+ may no longer be available. The async client exposes the same methods.
150
+
98
151
  ## Resources
99
152
 
100
153
  | Resource | Methods |
101
154
  |----------|---------|
102
- | `rl.mailboxes` | `create`, `list`, `delete`, `update`, `set_recipient_policy` |
155
+ | `rl.domains` | `create`, `list`, `get`, `verify`, `update_self_hosted_config`, `delete`, `set_default`, `recheck` |
156
+ | `rl.mailboxes` | `create`, `list`, `get_mailbox`, `delete`, `update`, `set_recipient_policy`, `set_thread_replies`, `set_agent_send_containment`, `set_agent_send_policy`, `set_attachment_access`, `set_sender_policy` |
103
157
  | `rl.mailboxes.allowlist` | `list`, `add`, `add_bulk`, `delete`, `list_blocked_attempts` |
104
- | `rl.messages` | `send`, `list`, `get`, `reply`, `wait`, `release`, `block`, `set_starred` |
158
+ | `rl.mailboxes.inbound_allowlist` | `list`, `add`, `add_bulk`, `delete`, `list_blocked_attempts` |
159
+ | `rl.messages` | `send`, `list`, `get`, `reply`, `get_idempotency_replay`, `wait`, `release`, `block`, `report`, `delete`, `firewall_release`, `mark_read`, `set_starred`, `approve_review`, `deny_review` |
105
160
  | `rl.drafts` | `create`, `get`, `list`, `update`, `send`, `delete` |
106
- | `rl.threads` | `list`, `get`, `set_starred` |
161
+ | `rl.threads` | `list`, `get`, `mark_read`, `set_starred` |
107
162
  | `rl.attachments` | `get_download_url`, `get_preview`, `upload`, `get_upload`, `delete_upload` |
108
163
  | `rl.webhooks` | `create`, `list`, `get`, `update`, `delete`, `rotate_secret`, `test`, `list_deliveries`, `retry_delivery` |
109
164
  | `rl.recipients` | `create`, `list`, `delete`, `resend` |
110
- | `rl.suppressions` | `list`, `delete` |
165
+ | `rl.suppressions` | `list`, `add`, `add_bulk`, `delete` |
166
+ | `rl.inbound_blocklist` | `list`, `add`, `add_bulk`, `delete` |
111
167
  | `rl.api_keys` | `create`, `list`, `revoke`, `rotate`* |
112
- | `rl.account` | `get_usage`, `get_quota`, `get_link_scanning_status`, `enable_link_scanning` |
168
+ | `rl.account` | `get_usage`, `get_quota`, `get_link_scanning_status`, `enable_link_scanning`, `export` |
169
+ | `rl.legal_holds` | `apply`, `release`, `list`, `get` |
113
170
  | `rl.health` | `check` |
114
171
 
115
172
  *`api_keys.rotate()` revokes the calling API key and returns a new one. After calling it, this SDK instance's key is invalidated — create a new `ReplyLayer` instance with the returned key.
@@ -130,12 +187,12 @@ if draft["worst_decision"] == "allow":
130
187
  print(f"Sent {result['message_id']}")
131
188
  ```
132
189
 
133
- The send/reply/draft-send response carries two additive, nullable keys that explain a held send inline (no second `messages.get` call). `result["scan"]` is the vendor-neutral scanner verdict (`ScanSummary`); `result["hold_context"]` (`{"trigger_source", "summary_reasons"}` or `None`) is the policy/HITL reason, non-null only when the delivery `status` diverges from `scan["verdict"]` because of a policy/HITL hold — a clean scan held for review by your mailbox policy, or a scanner review-flag held as quarantine on a plan without the review queue (`trigger_source`: `mailbox_policy` | `scanner` | `both`).
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`).
134
191
 
135
- By default `drafts.send()`, `messages.send()`, and `messages.reply()` return only once the scanner verdict is known, with `scan` and `hold_context` inline. Pass `async_dispatch=True` to `drafts.send()` to send the `Prefer: respond-async` hint. **The hint is advisory** — the server returns a `202 AsyncSendAck` only when `OUTBOUND_ASYNC_DISPATCH_ENABLED` is on; otherwise it ignores the hint and returns a normal `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.)
192
+ By default `drafts.send()`, `messages.send()`, and `messages.reply()` return only once the scanner verdict is known, with `scan` and `hold_context` inline. Pass `async_dispatch=True` to `drafts.send()` to send the `Prefer: respond-async` hint. **The hint is advisory — the server decides.** When async dispatch is available the server returns `202` with `status="queued_for_dispatch"` (`AsyncSendAck`); otherwise it ignores the hint and returns a normal `200` `SendMessageResponse`. **Always branch on the result**: `result["status"] == "queued_for_dispatch"` ⇒ `AsyncSendAck`, otherwise `SendMessageResponse`. Poll `messages.get(message_id)` (or handle the lifecycle webhook) until `state` is terminal. Attachment-bearing drafts fail closed on the async path (`400 ATTACHMENTS_REQUIRE_SYNC_SEND`). (`messages.wait()` is a mailbox long-poll for new *inbound* mail, not a way to observe a specific message by ID.)
136
193
 
137
194
  The send endpoint raises `ReplyLayerError` with distinct `.code` values on 409:
138
- - `DRAFT_REJECTED_BY_RESCAN` — send-time scan flipped the verdict to `block`/`quarantine`. The draft stays in `draft` state; edit the body and retry. `err.details` carries `scan`, `releasable` (`True` for a `quarantine` hold the customer can release via `POST /v1/drafts/:id/release-and-send`, `False` for a terminal `block`), and, when a policy/HITL decision drove the hold, `hold_context`.
195
+ - `DRAFT_REJECTED_BY_RESCAN` — send-time scan flipped the verdict to `block`/`quarantine`. The draft stays in `draft` state; edit the body and retry. `err.details` carries `scan`, `releasable` (`True` for a `quarantine` hold the customer can release via `POST /v1/drafts/:id/release-and-send`, `False` for a terminal `block`), and, when a policy/human-review decision drove the hold, `hold_context`.
139
196
  - `DRAFT_ALREADY_SENT` — the draft was already sent (race or retry after success).
140
197
 
141
198
  ```python
@@ -236,7 +293,7 @@ rl.mailboxes.update(
236
293
 
237
294
  ### Advanced PII config (Pro+)
238
295
 
239
- PR 8 added `pii_redaction_config` for **per-detector** control over redaction (e.g. "leave email visible, redact everything else") and **operator-level** rendering (`partial_mask` for credit cards, `hash_replace` for emails you want to dedupe without exposing). Pro+ feature; only meaningful when `pii_mode="redacted"`.
296
+ `pii_redaction_config` gives **per-detector** control over redaction (e.g. "leave email visible, redact everything else") and **operator-level** rendering (`partial_mask` for credit cards, `hash_replace` for emails you want to dedupe without exposing). Pro+ feature; only meaningful when `pii_mode="redacted"`.
240
297
 
241
298
  ```python
242
299
  # Per-detector toggle: show emails to the agent, keep everything else redacted.
@@ -330,11 +387,11 @@ Images are a separately confirmed raw-download family. When `allowed_file_famili
330
387
 
331
388
  Human dashboard sessions and admin/pre-scoping keys can download clean stored `metadata_only` attachments, including attachments held back from agent raw-download policy. Agent-role keys remain bound to the mailbox policy gate plus hard safety checks; all callers remain blocked by infected AV verdicts, non-terminal message states, missing stored bytes, and hard attachment blocks.
332
389
 
333
- See ENDPOINTS.md for the full contract and known limitations.
390
+ See the Mailboxes API reference at https://replylayer.ai/docs/api/mailboxes for the full contract and known limitations.
334
391
 
335
392
  ### Recipient allowlist (mailbox containment)
336
393
 
337
- A mailbox is in `blocklist` mode by default — the pre-send gate rejects `suppressed_addresses` hits and allows everyone else. Switching to `allowlist` mode restricts outbound to a pre-approved list; an agent (or a compromised API key) physically cannot email outside the list.
394
+ A mailbox is in `blocklist` mode by default — the pre-send gate rejects `suppressed_addresses` hits and allows everyone else. Switching to `allowlist` mode contains **agent-origin** outbound to a pre-approved list (plus thread participants): a prompt-injected or compromised **agent key** cannot email outside the list. It is a containment boundary against a hijacked agent, not an all-origin lock — a human send (your dashboard session or an admin API key) is not restricted by the allowlist; only your do-not-contact (suppression) list binds a human send.
338
395
 
339
396
  ```python
340
397
  # Populate the allowlist first. Admin-only — agent keys get 403 INSUFFICIENT_SCOPE.
@@ -356,9 +413,9 @@ rl.mailboxes.allowlist.delete(mailbox["id"], "partner@corp.com", force_empty=Tru
356
413
 
357
414
  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.
358
415
 
359
- Allowlist mutations are admin-only — granting send permission to an LLM defeats the containment boundary. Agents *can* `list` (so they can see what they're allowed to email) but not `add`/`add_bulk`/`delete`. Three new webhook events: `recipient_allowlist.added`, `recipient_allowlist.removed`, `mailbox.recipient_policy_changed`.
416
+ 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`.
360
417
 
361
- ### Domain entries (sprint 039)
418
+ ### Domain entries
362
419
 
363
420
  Entries can be either an exact email (`alice@corp.com`) or a bare-domain pattern (`@corp.com`) that matches every address at that domain. Exact-domain only — `@corp.com` matches `*@corp.com` but NOT `eve@sub.corp.com`.
364
421
 
@@ -383,7 +440,7 @@ Responses expose `pattern_type: "email" | "domain"` on every add/list/delete/bul
383
440
 
384
441
  Blocklist precedence still holds: a domain-block beats an exact-allow at the same domain. Malformed patterns (`@`, `@.com`, `@foo`, `@corp-.com`, non-ASCII) raise `ReplyLayerError` with `.code == "INVALID_EMAIL"` (message: `"Invalid email or domain pattern"`).
385
442
 
386
- ### Blocked attempts (migration 038)
443
+ ### Blocked attempts
387
444
 
388
445
  Every send the allowlist gate rejects writes an append-only audit row and emits a deduped `recipient_allowlist.blocked_attempt` webhook. Review the log to see what your agent tried to email and one-click add legitimate recipients.
389
446
 
@@ -443,7 +500,7 @@ if ctx and ctx.get("instruction_trust"):
443
500
  # content-safety judgment.
444
501
  print(ctx["guidance"])
445
502
  print(ctx["instruction_trust"])
446
- # {"version": "v1", "match": "address", "verified_domain": ..., "verdict": "verified_aligned", "provenance": "mailgun"}
503
+ # {"version": "v1", "match": "address", "verified_domain": ..., "verdict": "verified_aligned", "provenance": "managed"}
447
504
  ```
448
505
 
449
506
  A human account owner enables the mailbox mode and the key's capability, and designates the trusted sender, from the dashboard (each a loosening change requiring session re-auth). Both the mailbox mode and the per-key capability default to off, so existing integrations are unaffected until a customer opts in. This is a read-path signal only — a copied or hijacked API key cannot self-grant the capability, and there is nothing for a client to set to request it.
@@ -492,7 +549,7 @@ Error classes: `ReplyLayerError` (base), `AuthenticationError` (401), `Forbidden
492
549
 
493
550
  ## Webhook signature verification
494
551
 
495
- > For a full integration guide (event catalog, retry behavior, idempotency, security, troubleshooting), see the hosted webhook docs (coming).
552
+ > For a full integration guide (event catalog, retry behavior, idempotency, security, troubleshooting), see https://replylayer.ai/docs/webhooks.
496
553
 
497
554
  ```python
498
555
  from replylayer import verify_webhook_signature
@@ -513,9 +570,9 @@ import json
513
570
  payload = json.loads(request.body)
514
571
  # payload["event"] is the discriminator — NOT payload["type"]
515
572
  if payload["event"] == "message.received":
516
- # handle inbound message
573
+ print("handle inbound message")
517
574
  elif payload["event"] == "message.dispatch_failed":
518
- # handle failed outbound send
575
+ print("handle failed outbound send")
519
576
  ```
520
577
 
521
578
  ## Context managers
@@ -1,20 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: replylayer
3
- Version: 0.22.0
4
- Summary: Official Python SDK for ReplyLayer — email for AI agents
5
- License-Expression: MIT
6
- Keywords: agent,ai,email,mailbox,replylayer,sdk,webhook
7
- Requires-Python: >=3.10
8
- Requires-Dist: httpx>=0.27
9
- Requires-Dist: typing-extensions>=4.0
10
- Provides-Extra: cli
11
- Requires-Dist: rly>=0.6.3; extra == 'cli'
12
- Provides-Extra: dev
13
- Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
14
- Requires-Dist: pytest>=8.0; extra == 'dev'
15
- Requires-Dist: respx>=0.21; extra == 'dev'
16
- Description-Content-Type: text/markdown
17
-
18
1
  # replylayer
19
2
 
20
3
  Official Python SDK for [ReplyLayer](https://replylayer.ai) — secure email for AI agents.
@@ -97,7 +80,8 @@ contract — read it before relying on retries:
97
80
  - **`5xx` is retried only on non-mutating (`GET`) requests.** A `5xx` on a
98
81
  `POST` / `PATCH` / `DELETE` is **not** retried — the request may have executed,
99
82
  so a retry risks a double-send (or, for `DELETE`, retrying a lost-but-applied
100
- delete into a confusing `404`).
83
+ delete into a confusing `404`). To retry a `send` / `reply` safely, pass an
84
+ idempotency key — see [Idempotent sends](#idempotent-sends).
101
85
  - **Multipart uploads are never retried** (a retry would re-send the body).
102
86
  - **Long `Retry-After` values block up to `max_retry_after_seconds`** (default
103
87
  ~67 minutes, sized to ride out hour-bucket rate limits for batch jobs). When a
@@ -112,21 +96,57 @@ contract — read it before relying on retries:
112
96
  may be a coroutine (it's awaited); a raising callback is swallowed so it can't
113
97
  break the retry.
114
98
 
99
+ ## Idempotent sends
100
+
101
+ Because a `5xx` on a send is **not** auto-retried (a blind retry risks a second
102
+ delivery + a second charge), the SDK gives you a way to retry it yourself
103
+ *safely*. `messages.send`, `messages.reply`, and scheduled sends via
104
+ `drafts.create` (with `send_at`) accept an `idempotency_key`: a network-retried
105
+ request carrying the same key produces **at most one** message and one charge —
106
+ the server replays the original outcome and returns the **same `message_id`**
107
+ instead of sending again.
108
+
109
+ ```python
110
+ import uuid
111
+
112
+ key = str(uuid.uuid4()) # stable per send intent — persist it with the job
113
+
114
+ # First call sends; a same-key retry replays the first result (no second send).
115
+ sent = rl.messages.send(
116
+ from_mailbox="support",
117
+ to="user@example.com",
118
+ subject="Hi",
119
+ body="Hello",
120
+ idempotency_key=key,
121
+ )
122
+ print(sent["message_id"]) # a same-key retry returns this SAME id
123
+ ```
124
+
125
+ The key travels as the `Idempotency-Key` request header and is permanent (no
126
+ expiry). A non-throwing probe, `rl.messages.get_idempotency_replay(key)`, reports
127
+ whether a key already produced a result, is still in flight, or is a miss — call
128
+ it before a retry whose local inputs (a staged attachment, the original message)
129
+ may no longer be available. The async client exposes the same methods.
130
+
115
131
  ## Resources
116
132
 
117
133
  | Resource | Methods |
118
134
  |----------|---------|
119
- | `rl.mailboxes` | `create`, `list`, `delete`, `update`, `set_recipient_policy` |
135
+ | `rl.domains` | `create`, `list`, `get`, `verify`, `update_self_hosted_config`, `delete`, `set_default`, `recheck` |
136
+ | `rl.mailboxes` | `create`, `list`, `get_mailbox`, `delete`, `update`, `set_recipient_policy`, `set_thread_replies`, `set_agent_send_containment`, `set_agent_send_policy`, `set_attachment_access`, `set_sender_policy` |
120
137
  | `rl.mailboxes.allowlist` | `list`, `add`, `add_bulk`, `delete`, `list_blocked_attempts` |
121
- | `rl.messages` | `send`, `list`, `get`, `reply`, `wait`, `release`, `block`, `set_starred` |
138
+ | `rl.mailboxes.inbound_allowlist` | `list`, `add`, `add_bulk`, `delete`, `list_blocked_attempts` |
139
+ | `rl.messages` | `send`, `list`, `get`, `reply`, `get_idempotency_replay`, `wait`, `release`, `block`, `report`, `delete`, `firewall_release`, `mark_read`, `set_starred`, `approve_review`, `deny_review` |
122
140
  | `rl.drafts` | `create`, `get`, `list`, `update`, `send`, `delete` |
123
- | `rl.threads` | `list`, `get`, `set_starred` |
141
+ | `rl.threads` | `list`, `get`, `mark_read`, `set_starred` |
124
142
  | `rl.attachments` | `get_download_url`, `get_preview`, `upload`, `get_upload`, `delete_upload` |
125
143
  | `rl.webhooks` | `create`, `list`, `get`, `update`, `delete`, `rotate_secret`, `test`, `list_deliveries`, `retry_delivery` |
126
144
  | `rl.recipients` | `create`, `list`, `delete`, `resend` |
127
- | `rl.suppressions` | `list`, `delete` |
145
+ | `rl.suppressions` | `list`, `add`, `add_bulk`, `delete` |
146
+ | `rl.inbound_blocklist` | `list`, `add`, `add_bulk`, `delete` |
128
147
  | `rl.api_keys` | `create`, `list`, `revoke`, `rotate`* |
129
- | `rl.account` | `get_usage`, `get_quota`, `get_link_scanning_status`, `enable_link_scanning` |
148
+ | `rl.account` | `get_usage`, `get_quota`, `get_link_scanning_status`, `enable_link_scanning`, `export` |
149
+ | `rl.legal_holds` | `apply`, `release`, `list`, `get` |
130
150
  | `rl.health` | `check` |
131
151
 
132
152
  *`api_keys.rotate()` revokes the calling API key and returns a new one. After calling it, this SDK instance's key is invalidated — create a new `ReplyLayer` instance with the returned key.
@@ -147,12 +167,12 @@ if draft["worst_decision"] == "allow":
147
167
  print(f"Sent {result['message_id']}")
148
168
  ```
149
169
 
150
- The send/reply/draft-send response carries two additive, nullable keys that explain a held send inline (no second `messages.get` call). `result["scan"]` is the vendor-neutral scanner verdict (`ScanSummary`); `result["hold_context"]` (`{"trigger_source", "summary_reasons"}` or `None`) is the policy/HITL reason, non-null only when the delivery `status` diverges from `scan["verdict"]` because of a policy/HITL hold — a clean scan held for review by your mailbox policy, or a scanner review-flag held as quarantine on a plan without the review queue (`trigger_source`: `mailbox_policy` | `scanner` | `both`).
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`).
151
171
 
152
- By default `drafts.send()`, `messages.send()`, and `messages.reply()` return only once the scanner verdict is known, with `scan` and `hold_context` inline. Pass `async_dispatch=True` to `drafts.send()` to send the `Prefer: respond-async` hint. **The hint is advisory** — the server returns a `202 AsyncSendAck` only when `OUTBOUND_ASYNC_DISPATCH_ENABLED` is on; otherwise it ignores the hint and returns a normal `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.)
172
+ By default `drafts.send()`, `messages.send()`, and `messages.reply()` return only once the scanner verdict is known, with `scan` and `hold_context` inline. Pass `async_dispatch=True` to `drafts.send()` to send the `Prefer: respond-async` hint. **The hint is advisory — the server decides.** When async dispatch is available the server returns `202` with `status="queued_for_dispatch"` (`AsyncSendAck`); otherwise it ignores the hint and returns a normal `200` `SendMessageResponse`. **Always branch on the result**: `result["status"] == "queued_for_dispatch"` ⇒ `AsyncSendAck`, otherwise `SendMessageResponse`. Poll `messages.get(message_id)` (or handle the lifecycle webhook) until `state` is terminal. Attachment-bearing drafts fail closed on the async path (`400 ATTACHMENTS_REQUIRE_SYNC_SEND`). (`messages.wait()` is a mailbox long-poll for new *inbound* mail, not a way to observe a specific message by ID.)
153
173
 
154
174
  The send endpoint raises `ReplyLayerError` with distinct `.code` values on 409:
155
- - `DRAFT_REJECTED_BY_RESCAN` — send-time scan flipped the verdict to `block`/`quarantine`. The draft stays in `draft` state; edit the body and retry. `err.details` carries `scan`, `releasable` (`True` for a `quarantine` hold the customer can release via `POST /v1/drafts/:id/release-and-send`, `False` for a terminal `block`), and, when a policy/HITL decision drove the hold, `hold_context`.
175
+ - `DRAFT_REJECTED_BY_RESCAN` — send-time scan flipped the verdict to `block`/`quarantine`. The draft stays in `draft` state; edit the body and retry. `err.details` carries `scan`, `releasable` (`True` for a `quarantine` hold the customer can release via `POST /v1/drafts/:id/release-and-send`, `False` for a terminal `block`), and, when a policy/human-review decision drove the hold, `hold_context`.
156
176
  - `DRAFT_ALREADY_SENT` — the draft was already sent (race or retry after success).
157
177
 
158
178
  ```python
@@ -253,7 +273,7 @@ rl.mailboxes.update(
253
273
 
254
274
  ### Advanced PII config (Pro+)
255
275
 
256
- PR 8 added `pii_redaction_config` for **per-detector** control over redaction (e.g. "leave email visible, redact everything else") and **operator-level** rendering (`partial_mask` for credit cards, `hash_replace` for emails you want to dedupe without exposing). Pro+ feature; only meaningful when `pii_mode="redacted"`.
276
+ `pii_redaction_config` gives **per-detector** control over redaction (e.g. "leave email visible, redact everything else") and **operator-level** rendering (`partial_mask` for credit cards, `hash_replace` for emails you want to dedupe without exposing). Pro+ feature; only meaningful when `pii_mode="redacted"`.
257
277
 
258
278
  ```python
259
279
  # Per-detector toggle: show emails to the agent, keep everything else redacted.
@@ -347,11 +367,11 @@ Images are a separately confirmed raw-download family. When `allowed_file_famili
347
367
 
348
368
  Human dashboard sessions and admin/pre-scoping keys can download clean stored `metadata_only` attachments, including attachments held back from agent raw-download policy. Agent-role keys remain bound to the mailbox policy gate plus hard safety checks; all callers remain blocked by infected AV verdicts, non-terminal message states, missing stored bytes, and hard attachment blocks.
349
369
 
350
- See ENDPOINTS.md for the full contract and known limitations.
370
+ See the Mailboxes API reference at https://replylayer.ai/docs/api/mailboxes for the full contract and known limitations.
351
371
 
352
372
  ### Recipient allowlist (mailbox containment)
353
373
 
354
- A mailbox is in `blocklist` mode by default — the pre-send gate rejects `suppressed_addresses` hits and allows everyone else. Switching to `allowlist` mode restricts outbound to a pre-approved list; an agent (or a compromised API key) physically cannot email outside the list.
374
+ A mailbox is in `blocklist` mode by default — the pre-send gate rejects `suppressed_addresses` hits and allows everyone else. Switching to `allowlist` mode contains **agent-origin** outbound to a pre-approved list (plus thread participants): a prompt-injected or compromised **agent key** cannot email outside the list. It is a containment boundary against a hijacked agent, not an all-origin lock — a human send (your dashboard session or an admin API key) is not restricted by the allowlist; only your do-not-contact (suppression) list binds a human send.
355
375
 
356
376
  ```python
357
377
  # Populate the allowlist first. Admin-only — agent keys get 403 INSUFFICIENT_SCOPE.
@@ -373,9 +393,9 @@ rl.mailboxes.allowlist.delete(mailbox["id"], "partner@corp.com", force_empty=Tru
373
393
 
374
394
  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.
375
395
 
376
- Allowlist mutations are admin-only — granting send permission to an LLM defeats the containment boundary. Agents *can* `list` (so they can see what they're allowed to email) but not `add`/`add_bulk`/`delete`. Three new webhook events: `recipient_allowlist.added`, `recipient_allowlist.removed`, `mailbox.recipient_policy_changed`.
396
+ 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`.
377
397
 
378
- ### Domain entries (sprint 039)
398
+ ### Domain entries
379
399
 
380
400
  Entries can be either an exact email (`alice@corp.com`) or a bare-domain pattern (`@corp.com`) that matches every address at that domain. Exact-domain only — `@corp.com` matches `*@corp.com` but NOT `eve@sub.corp.com`.
381
401
 
@@ -400,7 +420,7 @@ Responses expose `pattern_type: "email" | "domain"` on every add/list/delete/bul
400
420
 
401
421
  Blocklist precedence still holds: a domain-block beats an exact-allow at the same domain. Malformed patterns (`@`, `@.com`, `@foo`, `@corp-.com`, non-ASCII) raise `ReplyLayerError` with `.code == "INVALID_EMAIL"` (message: `"Invalid email or domain pattern"`).
402
422
 
403
- ### Blocked attempts (migration 038)
423
+ ### Blocked attempts
404
424
 
405
425
  Every send the allowlist gate rejects writes an append-only audit row and emits a deduped `recipient_allowlist.blocked_attempt` webhook. Review the log to see what your agent tried to email and one-click add legitimate recipients.
406
426
 
@@ -460,7 +480,7 @@ if ctx and ctx.get("instruction_trust"):
460
480
  # content-safety judgment.
461
481
  print(ctx["guidance"])
462
482
  print(ctx["instruction_trust"])
463
- # {"version": "v1", "match": "address", "verified_domain": ..., "verdict": "verified_aligned", "provenance": "mailgun"}
483
+ # {"version": "v1", "match": "address", "verified_domain": ..., "verdict": "verified_aligned", "provenance": "managed"}
464
484
  ```
465
485
 
466
486
  A human account owner enables the mailbox mode and the key's capability, and designates the trusted sender, from the dashboard (each a loosening change requiring session re-auth). Both the mailbox mode and the per-key capability default to off, so existing integrations are unaffected until a customer opts in. This is a read-path signal only — a copied or hijacked API key cannot self-grant the capability, and there is nothing for a client to set to request it.
@@ -509,7 +529,7 @@ Error classes: `ReplyLayerError` (base), `AuthenticationError` (401), `Forbidden
509
529
 
510
530
  ## Webhook signature verification
511
531
 
512
- > For a full integration guide (event catalog, retry behavior, idempotency, security, troubleshooting), see the hosted webhook docs (coming).
532
+ > For a full integration guide (event catalog, retry behavior, idempotency, security, troubleshooting), see https://replylayer.ai/docs/webhooks.
513
533
 
514
534
  ```python
515
535
  from replylayer import verify_webhook_signature
@@ -530,9 +550,9 @@ import json
530
550
  payload = json.loads(request.body)
531
551
  # payload["event"] is the discriminator — NOT payload["type"]
532
552
  if payload["event"] == "message.received":
533
- # handle inbound message
553
+ print("handle inbound message")
534
554
  elif payload["event"] == "message.dispatch_failed":
535
- # handle failed outbound send
555
+ print("handle failed outbound send")
536
556
  ```
537
557
 
538
558
  ## Context managers
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "replylayer"
7
- version = "0.22.0"
7
+ version = "0.23.0"
8
8
  description = "Official Python SDK for ReplyLayer — email for AI agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -17,6 +17,11 @@ dependencies = [
17
17
  "typing_extensions>=4.0",
18
18
  ]
19
19
 
20
+ [project.urls]
21
+ Homepage = "https://replylayer.ai"
22
+ Repository = "https://github.com/replylayer/rly"
23
+ Issues = "https://github.com/replylayer/rly/issues"
24
+
20
25
  [project.optional-dependencies]
21
26
  # Optional CLI convenience: `pip install "replylayer[cli]"` also installs the
22
27
  # `rly` launcher (the `rly` / `replylayer` command-line tools). The SDK itself
@@ -101,7 +101,7 @@ from .types import (
101
101
  ScannerPolicy,
102
102
  )
103
103
 
104
- __version__ = "0.22.0"
104
+ __version__ = "0.23.0"
105
105
 
106
106
  __all__ = [
107
107
  # Message delete response (0.20.0).
@@ -9,7 +9,7 @@ import httpx
9
9
 
10
10
  from .errors import ReplyLayerError, error_from_response
11
11
 
12
- _VERSION = "0.22.0"
12
+ _VERSION = "0.23.0"
13
13
  _USER_AGENT = f"replylayer-sdk-py/{_VERSION}"
14
14
  _PROTECTED_HEADER_KEYS = frozenset({"authorization", "content-type", "user-agent"})
15
15
 
@@ -61,9 +61,9 @@ class WebhookSignatureError(ReplyLayerError):
61
61
 
62
62
 
63
63
  # Migration 040 — scheduled-send error surface. Raised when scheduled-send
64
- # routes reject a request (invalid TZ / too-soon / too-far / quota breach /
65
- # Idempotency-Key without send_at). Subclass of ReplyLayerError so generic
66
- # catches still work; typed .reason_code narrows for per-reason handling.
64
+ # routes reject a request (invalid TZ / too-soon / too-far / quota breach).
65
+ # Subclass of ReplyLayerError so generic catches still work; typed
66
+ # .reason_code narrows for per-reason handling.
67
67
  #
68
68
  # NOT raised for runtime dispatch failures — those surface as
69
69
  # message.dispatch_failed webhook events, not SDK exceptions.
@@ -72,7 +72,6 @@ _SCHEDULING_REASON_CODES: frozenset[str] = frozenset({
72
72
  "SEND_AT_TOO_SOON",
73
73
  "SEND_AT_TOO_FAR",
74
74
  "SCHEDULED_SEND_QUOTA_EXCEEDED",
75
- "IDEMPOTENCY_KEY_REQUIRES_SEND_AT",
76
75
  })
77
76
 
78
77
 
@@ -464,8 +464,9 @@ class SyncMailboxes:
464
464
  # the field". Mutually exclusive with raw recipient_policy_mode/
465
465
  # agent_send_containment in the same call (the server returns 400).
466
466
  agent_send_policy: AgentSendPolicy | None = None,
467
- # Consent for opening the agent on an allowlist mailbox (also opens
468
- # human sends). None/False omits it.
467
+ # Deprecated + ignored by the server: opening the agent on an allowlist
468
+ # mailbox no longer requires consent (the allowlist only bound the
469
+ # agent). Kept for back-compat. None/False omits it.
469
470
  confirm_open_human_sends: bool | None = None,
470
471
  # PR 8.1 — per-detector redaction visibility. Pass {} to reset to
471
472
  # platform default; pass a partial map (e.g.
@@ -555,11 +556,12 @@ class SyncMailboxes:
555
556
  stored field). ``"open"`` lets the agent send to any new recipient;
556
557
  ``"restricted"`` gates it. Session/admin only server-side.
557
558
 
558
- Opening on an allowlist mailbox also opens human sends, so the server
559
- returns 409 ``OPEN_AGENT_REQUIRES_OPEN_MAILBOX`` (with
560
- ``details.also_opens_human_sends = True``) on the first call. Re-issue
561
- with ``confirm_open_human_sends=True`` to atomically flip the mailbox to
562
- blocklist + containment off.
559
+ Opening on an allowlist mailbox now succeeds directly — the server
560
+ atomically flips the mailbox to blocklist and clears agent containment.
561
+ No consent is required (the allowlist only ever bound the agent, so
562
+ opening it does not affect human sends). ``confirm_open_human_sends`` is
563
+ **deprecated and ignored** by the server; it is still accepted here for
564
+ back-compat.
563
565
  """
564
566
  return self.update(
565
567
  id,
@@ -707,8 +709,9 @@ class AsyncMailboxes:
707
709
  # Mutually exclusive with raw recipient_policy_mode/agent_send_containment
708
710
  # in the same call (the server returns 400).
709
711
  agent_send_policy: AgentSendPolicy | None = None,
710
- # Consent for opening the agent on an allowlist mailbox (also opens
711
- # human sends). None/False omits it.
712
+ # Deprecated + ignored by the server: opening the agent on an allowlist
713
+ # mailbox no longer requires consent (the allowlist only bound the
714
+ # agent). Kept for back-compat. None/False omits it.
712
715
  confirm_open_human_sends: bool | None = None,
713
716
  # PR 8.1 — per-detector redaction visibility. Pass {} to reset to
714
717
  # platform default; pass a partial map to disable redaction on
@@ -796,10 +799,11 @@ class AsyncMailboxes:
796
799
 
797
800
  ``"open"`` lets the agent send to any new recipient; ``"restricted"``
798
801
  gates it. Session/admin only server-side. Opening on an allowlist
799
- mailbox also opens human sends, so the server returns 409
800
- ``OPEN_AGENT_REQUIRES_OPEN_MAILBOX``; re-issue with
801
- ``confirm_open_human_sends=True`` to atomically flip to blocklist +
802
- containment off.
802
+ mailbox now succeeds directly — the server atomically flips the mailbox
803
+ to blocklist and clears agent containment, with no consent required
804
+ (human sends are never allowlist-restricted). ``confirm_open_human_sends``
805
+ is **deprecated and ignored** by the server; still accepted for
806
+ back-compat.
803
807
  """
804
808
  return await self.update(
805
809
  id,
@@ -175,8 +175,8 @@ RecipientPolicyMode = Literal["blocklist", "allowlist"]
175
175
  AgentSendPolicy = Literal["restricted", "open"]
176
176
 
177
177
  # When agent_send_policy == "restricted", WHY:
178
- # - "mailbox_allowlist": the whole mailbox is allowlist (humans too); opening
179
- # the agent would also open human sends.
178
+ # - "mailbox_allowlist": the mailbox is in allowlist mode — the AGENT is
179
+ # restricted to the allowlist; human sends are unaffected (agent-only).
180
180
  # - "agent_containment": blocklist mailbox + the R3 containment overlay.
181
181
  # - None: agent_send_policy == "open".
182
182
  AgentSendRestrictedBy = Literal["mailbox_allowlist", "agent_containment"] | None
@@ -204,7 +204,7 @@ class FirewallBlock(TypedDict):
204
204
  matched_field: Literal["envelope", "from"] | None
205
205
  matched_pattern: str | None
206
206
  reason_code: Literal["SENDER_BLOCKED", "SENDER_NOT_ON_ALLOWLIST"]
207
- source_table: Literal["inbound_sender_blocklists", "inbound_sender_allowlists"] | None
207
+ matched_list: Literal["account_blocklist", "mailbox_allowlist"] | None
208
208
  mode: SenderPolicyMode
209
209
 
210
210
  # Sprint 039 — allowlist + suppression entries may be either an exact email
@@ -413,30 +413,32 @@ class Mailbox(TypedDict):
413
413
  status: MailboxStatus
414
414
  scanner_policy: ScannerPolicy | None
415
415
  pii_mode: PiiMode
416
- # Legacy attachment-access acceptance flag (migration 034). Retained for
417
- # backward-compatible reads and audit history, but effective attachment
418
- # exposure now comes from `attachment_exposure_mode` when set; otherwise
419
- # the mailbox behaves as `metadata_only`.
420
- attachment_access_enabled: bool
421
- attachment_access_accepted_at: str | None
422
- attachment_access_accepted_version: str | None
416
+ # Attachment / outbound-attachment CONSENT bookkeeping. SESSION-ONLY: the
417
+ # API returns these only to a dashboard (session-cookie) caller and strips
418
+ # them from every Bearer / API-key response — which includes this SDK. They
419
+ # drive the human re-acceptance banner and an agent cannot act on them (the
420
+ # acceptance routes are session + re-auth only), so they are NotRequired and,
421
+ # for SDK callers, always absent. Read `attachment_exposure_mode` instead.
422
+ attachment_access_enabled: NotRequired[bool]
423
+ attachment_access_accepted_at: NotRequired[str | None]
424
+ attachment_access_accepted_version: NotRequired[str | None]
423
425
  attachment_exposure_mode: AttachmentExposureMode
424
426
  attachment_allowed_file_families: list[AttachmentAllowedFileFamily]
425
- attachment_reauth_at: str | None
426
- attachment_policy_version: str | None
427
- image_raw_download_confirmed: bool
428
- current_image_risk_version: str
429
- attachment_image_access_accepted_at: str | None
430
- attachment_image_access_accepted_version: str | None
431
- # Always the current disclaimer version. Compare to accepted_version to
432
- # detect when a "disclaimer updated" banner should render.
433
- current_disclaimer_version: str
427
+ attachment_reauth_at: NotRequired[str | None]
428
+ attachment_policy_version: NotRequired[str | None]
429
+ image_raw_download_confirmed: NotRequired[bool]
430
+ current_image_risk_version: NotRequired[str]
431
+ attachment_image_access_accepted_at: NotRequired[str | None]
432
+ attachment_image_access_accepted_version: NotRequired[str | None]
433
+ # The current disclaimer version, for the dashboard re-acceptance banner.
434
+ # Session-only (see the consent note above) — absent on SDK/API-key reads.
435
+ current_disclaimer_version: NotRequired[str]
434
436
  # True when the mailbox is on the legacy compat path
435
437
  # (attachment_exposure_mode is null but attachment_access_enabled is true).
436
438
  # Drives the dashboard re-acceptance banner; flips to false after the
437
- # customer accepts an explicit mode. See
438
- # docs/runbooks/legacy-attachment-access-migration.md.
439
- legacy_wildcard_active: bool
439
+ # customer accepts an explicit mode. Session-only — absent on SDK/API-key
440
+ # reads. See docs/runbooks/legacy-attachment-access-migration.md.
441
+ legacy_wildcard_active: NotRequired[bool]
440
442
  # Migration 035 — default outbound sub-addressing rewrite mode.
441
443
  default_subaddress_mode: SubaddressMode
442
444
  # Migration 036 — recipient policy mode.
@@ -950,6 +952,7 @@ ScanCategory = Literal[
950
952
  "recipient_policy",
951
953
  "secret_detected",
952
954
  "content_similarity",
955
+ "delivery_warmup",
953
956
  "scan_incomplete",
954
957
  ]
955
958
 
@@ -1033,7 +1036,7 @@ class InstructionTrustBasis(TypedDict):
1033
1036
  # Verified sender's domain; None under pii_mode=redacted.
1034
1037
  verified_domain: str | None
1035
1038
  verdict: Literal["verified_aligned"]
1036
- provenance: Literal["mailgun"]
1039
+ provenance: Literal["managed"]
1037
1040
 
1038
1041
 
1039
1042
  class AgentSafetyContext(TypedDict):
@@ -1072,7 +1075,7 @@ class SenderAuthentication(TypedDict):
1072
1075
  verdict: SenderAuthVerdict
1073
1076
  from_domain: str | None
1074
1077
  signing_domain: str | None
1075
- provenance: Literal["mailgun", "self_hosted_imap"]
1078
+ provenance: Literal["managed", "self_hosted_imap"]
1076
1079
 
1077
1080
 
1078
1081
  class SenderAuthenticationCompact(TypedDict):
@@ -1815,12 +1818,26 @@ class QuotaToday(TypedDict):
1815
1818
  day: str
1816
1819
 
1817
1820
 
1821
+ # Present ONLY while a new paid account is inside its shared-domain warm-up
1822
+ # window (paid-velocity-gate); absent for every other account/state. `until`
1823
+ # is ISO-8601; `shared_domain_daily_limit` is the temporary daily send cap on
1824
+ # the shared platform pool; `velocity_gate_mode` reflects whether the inline
1825
+ # velocity control is enforcing yet; `reason` is customer-safe copy. Verify
1826
+ # your own sending domain (BYOD) to lift the warm-up immediately.
1827
+ class QuotaWarmup(TypedDict):
1828
+ until: str
1829
+ shared_domain_daily_limit: int
1830
+ velocity_gate_mode: Literal["log_only", "enforced"]
1831
+ reason: str
1832
+
1833
+
1818
1834
  class QuotaResponse(TypedDict):
1819
1835
  today: QuotaToday
1820
1836
  sends_remaining: int
1821
1837
  reset_at: str
1822
1838
  scope: Literal["admin", "agent"]
1823
1839
  bound_mailbox_ids: list[str]
1840
+ warmup: NotRequired[QuotaWarmup]
1824
1841
 
1825
1842
 
1826
1843
  # Malicious link scanning (URL reputation, backed by Google Web Risk).
@@ -472,19 +472,19 @@ def test_drafts_create_429_quota_exceeded_maps_to_scheduling_error_not_rate_limi
472
472
 
473
473
 
474
474
  @respx.mock
475
- def test_drafts_create_idempotency_key_without_send_at_maps_to_scheduling_error():
475
+ def test_drafts_create_send_at_too_far_maps_to_scheduling_error():
476
476
  respx.post(f"{BASE}/v1/drafts").mock(
477
477
  return_value=httpx.Response(400, json={
478
- "error": "Idempotency-Key is only accepted when send_at is present",
479
- "code": "IDEMPOTENCY_KEY_REQUIRES_SEND_AT",
478
+ "error": "send_at is beyond the scheduling horizon",
479
+ "code": "SEND_AT_TOO_FAR",
480
480
  }),
481
481
  )
482
482
  with pytest.raises(SchedulingError) as excinfo:
483
483
  sdk().drafts.create(
484
484
  mailbox_id="mbx1", to="r@example.com", subject="x", body="y",
485
- idempotency_key="k1",
485
+ send_at="2099-01-01T09:00:00Z",
486
486
  )
487
- assert excinfo.value.reason_code == "IDEMPOTENCY_KEY_REQUIRES_SEND_AT"
487
+ assert excinfo.value.reason_code == "SEND_AT_TOO_FAR"
488
488
 
489
489
 
490
490
  @respx.mock
@@ -45,7 +45,7 @@ def test_review_queued_payload_constructs_with_required_fields() -> None:
45
45
  "direction": "outbound",
46
46
  "subject": "Quarterly review",
47
47
  "recipient": "ceo@corp.example",
48
- "summary_reasons": ["Mailbox requires human approval for outbound mail (hitl_mode=all_outbound)"],
48
+ "summary_reasons": ['This mailbox requires human approval for all outbound mail (review setting: "All outbound").'],
49
49
  "origin": "fresh_send",
50
50
  }
51
51
  assert payload["origin"] == "fresh_send"
@@ -19,7 +19,7 @@ def test_instruction_trust_basis_type_is_exported():
19
19
  "match": "address",
20
20
  "verified_domain": "partner.com",
21
21
  "verdict": "verified_aligned",
22
- "provenance": "mailgun",
22
+ "provenance": "managed",
23
23
  }
24
24
  assert basis["verified_domain"] == "partner.com"
25
25
 
@@ -0,0 +1,80 @@
1
+ """B6 parity gate — the README "Resources" table must list EVERY public method
2
+ of EVERY resource the client exposes, and nothing that isn't a real method.
3
+
4
+ A stale table is the failure this locks out (the docs had drifted to
5
+ "suppressions has only list/delete" and "messages has 7 of 15 methods").
6
+ Reflect over a live client, parse the README table, and assert set equality so
7
+ adding/removing a method fails CI until the table is regenerated.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import inspect
13
+ import re
14
+ from pathlib import Path
15
+
16
+ from replylayer import ReplyLayer
17
+
18
+ README_PATH = Path(__file__).resolve().parent.parent / "README.md"
19
+
20
+
21
+ def _method_names(obj: object) -> list[str]:
22
+ """Public bound-method names on a resource instance, sorted.
23
+
24
+ ``inspect.ismethod`` returns only bound methods, so ``@staticmethod``
25
+ helpers (e.g. ``webhooks.verify_signature``) are excluded — matching the
26
+ table, which documents the top-level ``verify_webhook_signature`` helper
27
+ separately, not as a resource method.
28
+ """
29
+ return sorted(
30
+ name
31
+ for name, _ in inspect.getmembers(obj, predicate=inspect.ismethod)
32
+ if not name.startswith("_")
33
+ )
34
+
35
+
36
+ def _build_actual() -> dict[str, list[str]]:
37
+ out: dict[str, list[str]] = {}
38
+ with ReplyLayer(api_key="rly_live_test") as client:
39
+ for res_name, resource in vars(client).items():
40
+ if res_name.startswith("_"):
41
+ continue
42
+ out[res_name] = _method_names(resource)
43
+ # One level of nested resources (e.g. mailboxes.allowlist). The
44
+ # shared HTTP client is stored as the private ``_http`` attribute
45
+ # and is filtered out by the underscore guard.
46
+ for sub_name, sub in vars(resource).items():
47
+ if sub_name.startswith("_"):
48
+ continue
49
+ if _method_names(sub):
50
+ out[f"{res_name}.{sub_name}"] = _method_names(sub)
51
+ return out
52
+
53
+
54
+ def _parse_readme_table(md: str) -> dict[str, list[str]]:
55
+ out: dict[str, list[str]] = {}
56
+ for raw in md.splitlines():
57
+ line = raw.strip()
58
+ if not line.startswith("|"):
59
+ continue
60
+ cells = [c.strip() for c in line.split("|")]
61
+ resource_cell = cells[1] if len(cells) > 1 else ""
62
+ m = re.match(r"^`rl\.(.+)`$", resource_cell)
63
+ if not m: # header / separator / non-resource rows
64
+ continue
65
+ methods_cell = cells[2] if len(cells) > 2 else ""
66
+ out[m.group(1)] = sorted(re.findall(r"`([^`]+)`", methods_cell))
67
+ return out
68
+
69
+
70
+ _ACTUAL = _build_actual()
71
+ _DOCUMENTED = _parse_readme_table(README_PATH.read_text(encoding="utf-8"))
72
+
73
+
74
+ def test_readme_documents_all_resources() -> None:
75
+ assert sorted(_DOCUMENTED) == sorted(_ACTUAL)
76
+
77
+
78
+ def test_readme_lists_all_methods() -> None:
79
+ for key, methods in _ACTUAL.items():
80
+ assert _DOCUMENTED.get(key) == methods, f"methods for rl.{key}"
@@ -53,6 +53,21 @@ def test_account_get_usage_includes_storage():
53
53
  assert res["storage"]["breakdown"]["derivative_bytes"] == 34
54
54
 
55
55
 
56
+ QUOTA_RESPONSE_WARMUP = {
57
+ "today": {"count": 2, "limit": 233, "day": "2026-07-01"},
58
+ "sends_remaining": 231,
59
+ "reset_at": "2026-07-02T00:00:00.000Z",
60
+ "scope": "admin",
61
+ "bound_mailbox_ids": [],
62
+ "warmup": {
63
+ "until": "2026-07-04T12:00:00.000Z",
64
+ "shared_domain_daily_limit": 233,
65
+ "velocity_gate_mode": "log_only",
66
+ "reason": "New paid accounts ramp to full sending volume over their first few days on the shared pool.",
67
+ },
68
+ }
69
+
70
+
56
71
  @respx.mock
57
72
  def test_account_get_quota_returns_send_budget():
58
73
  route = respx.get(f"{BASE}/v1/accounts/quota").mock(return_value=httpx.Response(200, json=QUOTA_RESPONSE))
@@ -63,6 +78,21 @@ def test_account_get_quota_returns_send_budget():
63
78
  assert res["reset_at"] == "2026-05-31T00:00:00.000Z"
64
79
  assert res["scope"] == "agent"
65
80
  assert res["bound_mailbox_ids"] == ["mb-1", "mb-2"]
81
+ # `warmup` is optional and absent for a non-warm-up account.
82
+ assert "warmup" not in res
83
+
84
+
85
+ @respx.mock
86
+ def test_account_get_quota_passes_through_warmup():
87
+ route = respx.get(f"{BASE}/v1/accounts/quota").mock(
88
+ return_value=httpx.Response(200, json=QUOTA_RESPONSE_WARMUP)
89
+ )
90
+ res = sdk().account.get_quota()
91
+ assert route.called
92
+ assert res["warmup"]["until"] == "2026-07-04T12:00:00.000Z"
93
+ assert res["warmup"]["shared_domain_daily_limit"] == 233
94
+ assert res["warmup"]["velocity_gate_mode"] == "log_only"
95
+ assert "ramp" in res["warmup"]["reason"]
66
96
 
67
97
 
68
98
  @respx.mock
File without changes
File without changes