replylayer 0.22.0__tar.gz → 0.22.1__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.22.1/PKG-INFO +69 -12
  2. replylayer-0.22.0/PKG-INFO → replylayer-0.22.1/README.md +49 -29
  3. {replylayer-0.22.0 → replylayer-0.22.1}/pyproject.toml +6 -1
  4. {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/__init__.py +1 -1
  5. {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/_http.py +1 -1
  6. {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/errors.py +3 -4
  7. {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/types.py +15 -0
  8. {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_drafts.py +5 -5
  9. replylayer-0.22.1/tests/test_readme_resource_parity.py +80 -0
  10. {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_resources.py +30 -0
  11. {replylayer-0.22.0 → replylayer-0.22.1}/.gitignore +0 -0
  12. {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/__main__.py +0 -0
  13. {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/_client.py +0 -0
  14. {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/_pagination.py +0 -0
  15. {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/py.typed +0 -0
  16. {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/__init__.py +0 -0
  17. {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/account.py +0 -0
  18. {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/api_keys.py +0 -0
  19. {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/attachments.py +0 -0
  20. {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/domains.py +0 -0
  21. {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/drafts.py +0 -0
  22. {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/health.py +0 -0
  23. {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/inbound_blocklist.py +0 -0
  24. {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/legal_holds.py +0 -0
  25. {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/mailboxes.py +0 -0
  26. {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/messages.py +0 -0
  27. {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/recipients.py +0 -0
  28. {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/suppressions.py +0 -0
  29. {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/threads.py +0 -0
  30. {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/webhooks.py +0 -0
  31. {replylayer-0.22.0 → replylayer-0.22.1}/tests/__init__.py +0 -0
  32. {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_async.py +0 -0
  33. {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_attachments.py +0 -0
  34. {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_client.py +0 -0
  35. {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_domains.py +0 -0
  36. {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_governed_email_effect.py +0 -0
  37. {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_hitl_review_types.py +0 -0
  38. {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_http.py +0 -0
  39. {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_instruction_trust.py +0 -0
  40. {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_messages_idempotency.py +0 -0
  41. {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_threads.py +0 -0
  42. {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_version.py +0 -0
  43. {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_web_risk_types.py +0 -0
  44. {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_webhooks.py +0 -0
  45. {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_ws1_ws6.py +0 -0
  46. {replylayer-0.22.0 → replylayer-0.22.1}/uv.lock +0 -0
@@ -1,3 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: replylayer
3
+ Version: 0.22.1
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.
@@ -132,7 +189,7 @@ if draft["worst_decision"] == "allow":
132
189
 
133
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/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`).
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
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/HITL decision drove the hold, `hold_context`.
@@ -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.
@@ -358,7 +415,7 @@ A send/reply/draft-send to a recipient on your do-not-contact (suppression) list
358
415
 
359
416
  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`.
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
 
@@ -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.
@@ -149,7 +169,7 @@ if draft["worst_decision"] == "allow":
149
169
 
150
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/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`).
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
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/HITL decision drove the hold, `hold_context`.
@@ -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.
@@ -375,7 +395,7 @@ A send/reply/draft-send to a recipient on your do-not-contact (suppression) list
375
395
 
376
396
  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`.
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
 
@@ -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.22.1"
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.22.1"
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.22.1"
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
 
@@ -950,6 +950,7 @@ ScanCategory = Literal[
950
950
  "recipient_policy",
951
951
  "secret_detected",
952
952
  "content_similarity",
953
+ "delivery_warmup",
953
954
  "scan_incomplete",
954
955
  ]
955
956
 
@@ -1815,12 +1816,26 @@ class QuotaToday(TypedDict):
1815
1816
  day: str
1816
1817
 
1817
1818
 
1819
+ # Present ONLY while a new paid account is inside its shared-domain warm-up
1820
+ # window (paid-velocity-gate); absent for every other account/state. `until`
1821
+ # is ISO-8601; `shared_domain_daily_limit` is the temporary daily send cap on
1822
+ # the shared platform pool; `velocity_gate_mode` reflects whether the inline
1823
+ # velocity control is enforcing yet; `reason` is customer-safe copy. Verify
1824
+ # your own sending domain (BYOD) to lift the warm-up immediately.
1825
+ class QuotaWarmup(TypedDict):
1826
+ until: str
1827
+ shared_domain_daily_limit: int
1828
+ velocity_gate_mode: Literal["log_only", "enforced"]
1829
+ reason: str
1830
+
1831
+
1818
1832
  class QuotaResponse(TypedDict):
1819
1833
  today: QuotaToday
1820
1834
  sends_remaining: int
1821
1835
  reset_at: str
1822
1836
  scope: Literal["admin", "agent"]
1823
1837
  bound_mailbox_ids: list[str]
1838
+ warmup: NotRequired[QuotaWarmup]
1824
1839
 
1825
1840
 
1826
1841
  # 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
@@ -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