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.
- replylayer-0.22.0/README.md → replylayer-0.22.1/PKG-INFO +69 -12
- replylayer-0.22.0/PKG-INFO → replylayer-0.22.1/README.md +49 -29
- {replylayer-0.22.0 → replylayer-0.22.1}/pyproject.toml +6 -1
- {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/__init__.py +1 -1
- {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/_http.py +1 -1
- {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/errors.py +3 -4
- {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/types.py +15 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_drafts.py +5 -5
- replylayer-0.22.1/tests/test_readme_resource_parity.py +80 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_resources.py +30 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/.gitignore +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/__main__.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/_client.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/_pagination.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/py.typed +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/__init__.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/account.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/api_keys.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/attachments.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/domains.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/drafts.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/health.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/inbound_blocklist.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/legal_holds.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/mailboxes.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/messages.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/recipients.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/suppressions.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/threads.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/replylayer/resources/webhooks.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/tests/__init__.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_async.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_attachments.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_client.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_domains.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_governed_email_effect.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_hitl_review_types.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_http.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_instruction_trust.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_messages_idempotency.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_threads.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_version.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_web_risk_types.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_webhooks.py +0 -0
- {replylayer-0.22.0 → replylayer-0.22.1}/tests/test_ws1_ws6.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
573
|
+
print("handle inbound message")
|
|
517
574
|
elif payload["event"] == "message.dispatch_failed":
|
|
518
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
553
|
+
print("handle inbound message")
|
|
534
554
|
elif payload["event"] == "message.dispatch_failed":
|
|
535
|
-
|
|
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.
|
|
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
|
|
@@ -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
|
-
#
|
|
66
|
-
#
|
|
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
|
|
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": "
|
|
479
|
-
"code": "
|
|
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
|
-
|
|
485
|
+
send_at="2099-01-01T09:00:00Z",
|
|
486
486
|
)
|
|
487
|
-
assert excinfo.value.reason_code == "
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|