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.
- replylayer-0.22.0/README.md → replylayer-0.23.0/PKG-INFO +76 -19
- replylayer-0.22.0/PKG-INFO → replylayer-0.23.0/README.md +56 -36
- {replylayer-0.22.0 → replylayer-0.23.0}/pyproject.toml +6 -1
- {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/__init__.py +1 -1
- {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/_http.py +1 -1
- {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/errors.py +3 -4
- {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/mailboxes.py +17 -13
- {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/types.py +41 -24
- {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_drafts.py +5 -5
- {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_hitl_review_types.py +1 -1
- {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_instruction_trust.py +1 -1
- replylayer-0.23.0/tests/test_readme_resource_parity.py +80 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_resources.py +30 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/.gitignore +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/__main__.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/_client.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/_pagination.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/py.typed +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/__init__.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/account.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/api_keys.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/attachments.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/domains.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/drafts.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/health.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/inbound_blocklist.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/legal_holds.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/messages.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/recipients.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/suppressions.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/threads.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/replylayer/resources/webhooks.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/tests/__init__.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_async.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_attachments.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_client.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_domains.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_governed_email_effect.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_http.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_messages_idempotency.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_threads.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_version.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_web_risk_types.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_webhooks.py +0 -0
- {replylayer-0.22.0 → replylayer-0.23.0}/tests/test_ws1_ws6.py +0 -0
- {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.
|
|
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.
|
|
@@ -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/
|
|
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
|
|
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/
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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": "
|
|
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
|
|
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
|
-
|
|
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.
|
|
@@ -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/
|
|
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
|
|
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/
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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": "
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
@@ -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
|
|
|
@@ -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
|
-
#
|
|
468
|
-
#
|
|
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
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
#
|
|
711
|
-
#
|
|
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
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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
|
|
179
|
-
# the
|
|
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
|
-
|
|
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
|
-
#
|
|
417
|
-
#
|
|
418
|
-
#
|
|
419
|
-
# the
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
#
|
|
432
|
-
#
|
|
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.
|
|
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["
|
|
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["
|
|
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
|
|
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
|
|
@@ -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": [
|
|
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": "
|
|
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
|
|
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
|