replylayer 0.14.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.14.0/.gitignore +54 -0
- replylayer-0.14.0/PKG-INFO +502 -0
- replylayer-0.14.0/README.md +487 -0
- replylayer-0.14.0/pyproject.toml +31 -0
- replylayer-0.14.0/replylayer/__init__.py +143 -0
- replylayer-0.14.0/replylayer/_client.py +128 -0
- replylayer-0.14.0/replylayer/_http.py +326 -0
- replylayer-0.14.0/replylayer/_pagination.py +34 -0
- replylayer-0.14.0/replylayer/errors.py +152 -0
- replylayer-0.14.0/replylayer/py.typed +0 -0
- replylayer-0.14.0/replylayer/resources/__init__.py +0 -0
- replylayer-0.14.0/replylayer/resources/account.py +36 -0
- replylayer-0.14.0/replylayer/resources/api_keys.py +59 -0
- replylayer-0.14.0/replylayer/resources/attachments.py +115 -0
- replylayer-0.14.0/replylayer/resources/domains.py +79 -0
- replylayer-0.14.0/replylayer/resources/drafts.py +342 -0
- replylayer-0.14.0/replylayer/resources/health.py +21 -0
- replylayer-0.14.0/replylayer/resources/inbound_blocklist.py +93 -0
- replylayer-0.14.0/replylayer/resources/legal_holds.py +107 -0
- replylayer-0.14.0/replylayer/resources/mailboxes.py +768 -0
- replylayer-0.14.0/replylayer/resources/messages.py +425 -0
- replylayer-0.14.0/replylayer/resources/recipients.py +59 -0
- replylayer-0.14.0/replylayer/resources/suppressions.py +84 -0
- replylayer-0.14.0/replylayer/resources/threads.py +117 -0
- replylayer-0.14.0/replylayer/resources/webhooks.py +175 -0
- replylayer-0.14.0/replylayer/types.py +1578 -0
- replylayer-0.14.0/tests/__init__.py +0 -0
- replylayer-0.14.0/tests/test_async.py +429 -0
- replylayer-0.14.0/tests/test_attachments.py +354 -0
- replylayer-0.14.0/tests/test_client.py +46 -0
- replylayer-0.14.0/tests/test_domains.py +157 -0
- replylayer-0.14.0/tests/test_drafts.py +507 -0
- replylayer-0.14.0/tests/test_hitl_review_types.py +118 -0
- replylayer-0.14.0/tests/test_http.py +356 -0
- replylayer-0.14.0/tests/test_resources.py +522 -0
- replylayer-0.14.0/tests/test_web_risk_types.py +264 -0
- replylayer-0.14.0/tests/test_webhooks.py +213 -0
- replylayer-0.14.0/uv.lock +269 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
.env
|
|
4
|
+
.env.*
|
|
5
|
+
*.env
|
|
6
|
+
!.env.example
|
|
7
|
+
.claude/
|
|
8
|
+
.antigravitycli/
|
|
9
|
+
*.log
|
|
10
|
+
coverage/
|
|
11
|
+
.turbo/
|
|
12
|
+
*.tsbuildinfo
|
|
13
|
+
__pycache__/
|
|
14
|
+
*.pyc
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.venv/
|
|
17
|
+
*.egg-info/
|
|
18
|
+
.DS_Store
|
|
19
|
+
# Local npm pack tarballs (W0/W4 use these for pre-publish artifact verification)
|
|
20
|
+
packages/cli/*.tgz
|
|
21
|
+
# CLI single-executable-app (SEA) build output — generated by the binary
|
|
22
|
+
# packaging step; the release artifacts are built fresh in CI, never committed.
|
|
23
|
+
packages/cli/sea-prep/
|
|
24
|
+
benchmarks/cost-experiment/results.json
|
|
25
|
+
benchmarks/cost-experiment/*results*.json
|
|
26
|
+
|
|
27
|
+
# Stray tsc output in sdk src/ — real output lives in packages/sdk/dist/
|
|
28
|
+
packages/sdk/src/**/*.js
|
|
29
|
+
packages/sdk/src/**/*.js.map
|
|
30
|
+
packages/sdk/src/**/*.d.ts
|
|
31
|
+
packages/sdk/src/**/*.d.ts.map
|
|
32
|
+
|
|
33
|
+
# Guardrail-proxy eval/opt run outputs (corpora are tracked, results are not)
|
|
34
|
+
services/guardrail-proxy/eval_results*.json
|
|
35
|
+
services/guardrail-proxy/opt_round*.json
|
|
36
|
+
services/guardrail-proxy/oos_*_eval.json
|
|
37
|
+
scripts/.smoke-byoes-fixture.env
|
|
38
|
+
scripts/.smoke-byoes-extras.env
|
|
39
|
+
|
|
40
|
+
# Local-only archive of stray/orphaned files we want off the working tree
|
|
41
|
+
# but preserved on disk for forensics. Never committed.
|
|
42
|
+
archive/
|
|
43
|
+
|
|
44
|
+
# Operational briefings — local operator notes, never committed.
|
|
45
|
+
docs/briefings/
|
|
46
|
+
plans/fault-briefings/
|
|
47
|
+
|
|
48
|
+
# Allow eval-baseline logs as audit-trail evidence
|
|
49
|
+
!services/guardrail-proxy/eval_baselines/*.log
|
|
50
|
+
|
|
51
|
+
# Allow stress-test scenario evidence (stdout.log + proxy-log-excerpt.log)
|
|
52
|
+
# under audits/ — these are signed-verdict-anchored evidence per
|
|
53
|
+
# plans/granite-guardian-failover-saturation-stress-test.md §11.
|
|
54
|
+
!audits/**/*.log
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: replylayer
|
|
3
|
+
Version: 0.14.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: dev
|
|
11
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# replylayer
|
|
17
|
+
|
|
18
|
+
Official Python SDK for [ReplyLayer](https://replylayer.ai) — secure email for AI agents.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install replylayer
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick start
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from replylayer import ReplyLayer
|
|
30
|
+
|
|
31
|
+
rl = ReplyLayer(api_key="rly_live_k3m9p2qx7vn4hjd0.uZ8Qb1vK3mN0pR7sT2wX9yA4cF6gH8jL1nP3rT5vW7z")
|
|
32
|
+
|
|
33
|
+
# Create a mailbox
|
|
34
|
+
mailbox = rl.mailboxes.create(name="support")
|
|
35
|
+
|
|
36
|
+
# Send an email
|
|
37
|
+
sent = rl.messages.send(
|
|
38
|
+
from_mailbox=mailbox["name"],
|
|
39
|
+
to="user@example.com",
|
|
40
|
+
subject="Hello from my agent",
|
|
41
|
+
body="Hi there!",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Wait for a reply (long-poll, up to 30s)
|
|
45
|
+
result = rl.messages.wait(mailbox["id"])
|
|
46
|
+
if result["message"]:
|
|
47
|
+
msg = result["message"]
|
|
48
|
+
print(f"Got reply from {msg['sender']}: {msg['subject']}")
|
|
49
|
+
|
|
50
|
+
# Browse conversation threads
|
|
51
|
+
page = rl.threads.list(mailbox["id"])
|
|
52
|
+
for thread in page.data:
|
|
53
|
+
print(f"{thread['subject']} ({thread['message_count']} messages)")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Async usage
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from replylayer import AsyncReplyLayer
|
|
60
|
+
|
|
61
|
+
async with AsyncReplyLayer(api_key="rly_live_k3m9p2qx7vn4hjd0.uZ8Qb1vK3mN0pR7sT2wX9yA4cF6gH8jL1nP3rT5vW7z") as rl:
|
|
62
|
+
mailbox = await rl.mailboxes.create(name="support")
|
|
63
|
+
sent = await rl.messages.send(
|
|
64
|
+
from_mailbox=mailbox["name"],
|
|
65
|
+
to="user@example.com",
|
|
66
|
+
subject="Hello",
|
|
67
|
+
body="Hi!",
|
|
68
|
+
)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Constructor options
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
ReplyLayer(
|
|
75
|
+
api_key="rly_live_k3m9p2qx7vn4hjd0.uZ8Qb1vK3mN0pR7sT2wX9yA4cF6gH8jL1nP3rT5vW7z", # required
|
|
76
|
+
base_url="https://api.replylayer.ai", # default
|
|
77
|
+
max_retries=3, # retries on 429/5xx (0 = fail-fast)
|
|
78
|
+
timeout=30.0, # seconds per request
|
|
79
|
+
max_retry_after_seconds=4000.0, # cap on honoring a 429 Retry-After (~67min)
|
|
80
|
+
on_retry=None, # silent-by-default retry hook
|
|
81
|
+
)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Retry behavior
|
|
85
|
+
|
|
86
|
+
The client retries failed requests up to `max_retries` times (default `3`). The
|
|
87
|
+
contract — read it before relying on retries:
|
|
88
|
+
|
|
89
|
+
- **`429` is retried on *every* method, including mutating ones** (`POST` /
|
|
90
|
+
`PATCH` / `DELETE`). A `429` is a pre-dispatch rate-limit rejection, so nothing
|
|
91
|
+
happened server-side — retrying is safe. The wait honors the `Retry-After`
|
|
92
|
+
header.
|
|
93
|
+
- **`5xx` is retried only on non-mutating (`GET`) requests.** A `5xx` on a
|
|
94
|
+
`POST` / `PATCH` / `DELETE` is **not** retried — the request may have executed,
|
|
95
|
+
so a retry risks a double-send (or, for `DELETE`, retrying a lost-but-applied
|
|
96
|
+
delete into a confusing `404`).
|
|
97
|
+
- **Multipart uploads are never retried** (a retry would re-send the body).
|
|
98
|
+
- **Long `Retry-After` values block up to `max_retry_after_seconds`** (default
|
|
99
|
+
~67 minutes, sized to ride out hour-bucket rate limits for batch jobs). When a
|
|
100
|
+
server `Retry-After` *exceeds* this cap, the SDK **raises the `RateLimitError`**
|
|
101
|
+
rather than sleeping — it never clamps-and-retries into a still-limited window.
|
|
102
|
+
Interactive callers should set a low cap (e.g. `max_retry_after_seconds=30`).
|
|
103
|
+
- **`max_retries=0` is fail-fast** — no implicit retry of any kind. Recommended
|
|
104
|
+
for interactive / agent contexts. Branch on `RateLimitError.retry_after`.
|
|
105
|
+
- **`on_retry` is silent by default** — the SDK never writes to stdout/stderr.
|
|
106
|
+
Pass an `on_retry(info)` hook to log or meter retries; it receives a `RetryInfo`
|
|
107
|
+
(`attempt`, `error`, `delay_seconds`, `method`, `path`). On the async client it
|
|
108
|
+
may be a coroutine (it's awaited); a raising callback is swallowed so it can't
|
|
109
|
+
break the retry.
|
|
110
|
+
|
|
111
|
+
## Resources
|
|
112
|
+
|
|
113
|
+
| Resource | Methods |
|
|
114
|
+
|----------|---------|
|
|
115
|
+
| `rl.mailboxes` | `create`, `list`, `delete`, `update`, `set_recipient_policy` |
|
|
116
|
+
| `rl.mailboxes.allowlist` | `list`, `add`, `add_bulk`, `delete`, `list_blocked_attempts` |
|
|
117
|
+
| `rl.messages` | `send`, `list`, `get`, `reply`, `wait`, `release`, `block` |
|
|
118
|
+
| `rl.drafts` | `create`, `get`, `list`, `update`, `send`, `delete` |
|
|
119
|
+
| `rl.threads` | `list`, `get` |
|
|
120
|
+
| `rl.attachments` | `get_download_url`, `get_preview`, `upload`, `get_upload`, `delete_upload` |
|
|
121
|
+
| `rl.webhooks` | `create`, `list`, `get`, `update`, `delete`, `rotate_secret`, `test`, `list_deliveries`, `retry_delivery` |
|
|
122
|
+
| `rl.recipients` | `create`, `list`, `delete`, `resend` |
|
|
123
|
+
| `rl.suppressions` | `list`, `delete` |
|
|
124
|
+
| `rl.api_keys` | `create`, `list`, `revoke`, `rotate`* |
|
|
125
|
+
| `rl.account` | `get_usage` |
|
|
126
|
+
| `rl.health` | `check` |
|
|
127
|
+
|
|
128
|
+
*`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.
|
|
129
|
+
|
|
130
|
+
## Drafts: scan-then-review-then-send
|
|
131
|
+
|
|
132
|
+
`rl.drafts.create()` runs the outbound scanner synchronously and attaches the verdict to the draft. The create-time verdict is UX — it lets an agent (or a human approver) see the likely outcome before clicking send. `rl.drafts.send()` **re-runs the scanner authoritatively** against the mailbox's current policy, so a stale cached verdict cannot slip through.
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
draft = rl.drafts.create(
|
|
136
|
+
mailbox_id=mailbox["name"],
|
|
137
|
+
to="user@example.com",
|
|
138
|
+
subject="Re: your invoice",
|
|
139
|
+
body="Thanks for your question.",
|
|
140
|
+
)
|
|
141
|
+
if draft["worst_decision"] == "allow":
|
|
142
|
+
result = rl.drafts.send(draft["id"])
|
|
143
|
+
print(f"Sent {result['message_id']}")
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
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`).
|
|
147
|
+
|
|
148
|
+
This SDK always sends **synchronously** — `drafts.send()`, `messages.send()`, and `messages.reply()` return only once the scanner verdict is known, with `scan` and `hold_context` inline. The optimistic-ack async path (`Prefer: respond-async` → `202 queued_for_dispatch`, then poll the message to a terminal state) is a REST-level capability of `POST /v1/drafts/:id/send` only; the SDK exposes no `Prefer` option. To use it, drive that route directly (see ENDPOINTS.md "Asynchronous send (optimistic-ack) & polling") and poll `messages.get(message_id)` (or handle the lifecycle webhook) until `state` is terminal. (`messages.wait()` is a mailbox long-poll for new *inbound* mail, not a way to observe a specific message by ID.)
|
|
149
|
+
|
|
150
|
+
The send endpoint raises `ReplyLayerError` with distinct `.code` values on 409:
|
|
151
|
+
- `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` and, when a policy/HITL decision drove the hold, `hold_context`.
|
|
152
|
+
- `DRAFT_ALREADY_SENT` — the draft was already sent (race or retry after success).
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from replylayer import ReplyLayerError
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
rl.drafts.send(draft["id"])
|
|
159
|
+
except ReplyLayerError as err:
|
|
160
|
+
if err.code == "DRAFT_REJECTED_BY_RESCAN":
|
|
161
|
+
print("Rescan blocked it:", err.details)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Outbound attachments (Pro+)
|
|
165
|
+
|
|
166
|
+
Attaching a file is a **two-phase** flow: upload the bytes to stage a handle, then reference `handle["id"]` in a send/reply/draft `attachment_ids` list. Every attachment is scanned (byte-level family validation + AV + secrets/PII over extracted text **and** filename) before it leaves. The mailbox must have outbound attachments **explicitly enabled** (a Pro+, session-gated dashboard action) — uploads to a non-enabled mailbox raise `ForbiddenError` with `code="OUTBOUND_ATTACHMENTS_DISABLED"`.
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
import time
|
|
170
|
+
|
|
171
|
+
# Phase 1 — stage the file (returns an opaque handle).
|
|
172
|
+
with open("invoice.pdf", "rb") as f:
|
|
173
|
+
handle = rl.attachments.upload(
|
|
174
|
+
mailbox_id="support",
|
|
175
|
+
file=f.read(), # bytes or a file-like object
|
|
176
|
+
filename="invoice.pdf",
|
|
177
|
+
content_type="application/pdf", # advisory — the server re-sniffs the bytes
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# The content scan runs asynchronously. Poll until it leaves "pending".
|
|
181
|
+
status = handle["content_scan_status"] # "pending" at upload time
|
|
182
|
+
while status == "pending":
|
|
183
|
+
time.sleep(1)
|
|
184
|
+
polled = rl.attachments.get_upload(handle["id"])
|
|
185
|
+
if polled.get("status") == "consumed":
|
|
186
|
+
break
|
|
187
|
+
status = polled["content_scan_status"]
|
|
188
|
+
|
|
189
|
+
# Phase 2 — reference the handle on a send. "clean" and "flagged" both send
|
|
190
|
+
# (a "flagged" finding flows to the message verdict, like a body finding);
|
|
191
|
+
# "error" is fail-closed.
|
|
192
|
+
result = rl.messages.send(
|
|
193
|
+
from_mailbox="support",
|
|
194
|
+
to="user@example.com",
|
|
195
|
+
subject="Your invoice",
|
|
196
|
+
body="Attached.",
|
|
197
|
+
attachment_ids=[handle["id"]],
|
|
198
|
+
)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
A handle is **consumed once** at send and is single-mailbox-scoped (upload to the same mailbox you send from). Unconsumed handles expire after 24h; delete one early with `rl.attachments.delete_upload(handle["id"])`. Limits: 10 MB/file, 10 attachments and 15 MB total per message. Image attachments require a separate one-time image-risk disclaimer on the mailbox (`OUTBOUND_IMAGE_DISCLAIMER_REQUIRED`). Drafts hold handles and consume them at dispatch; `rl.drafts.update(draft_id, attachment_ids=None)` clears a draft's attachments. Attachment bytes are stored with provider-managed encryption-at-rest and transmitted over TLS — this is not end-to-end / zero-access encryption (the platform scans attachment content).
|
|
202
|
+
|
|
203
|
+
## Delivery history & manual retry
|
|
204
|
+
|
|
205
|
+
`rl.webhooks.list_deliveries(id, limit=..., before_at=..., before_id=...)` returns the most recent delivery attempts for a webhook with tuple-cursor keyset pagination. `before_at` and `before_id` must be provided together — the SDK omits the cursor entirely if only one is given.
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
page = rl.webhooks.list_deliveries(webhook_id, limit=50)
|
|
209
|
+
while page["has_more"]:
|
|
210
|
+
page = rl.webhooks.list_deliveries(
|
|
211
|
+
webhook_id,
|
|
212
|
+
limit=50,
|
|
213
|
+
before_at=page["next_before_at"],
|
|
214
|
+
before_id=page["next_before_id"],
|
|
215
|
+
)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`rl.webhooks.retry_delivery(webhook_id, delivery_id)` re-queues a single `failed` delivery. The API rejects retries on non-failed deliveries or deliveries whose parent webhook is disabled — surfaced as `ReplyLayerError` with `.code` set to `DELIVERY_NOT_FAILED` or `WEBHOOK_DISABLED`:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
from replylayer import ReplyLayer, ReplyLayerError
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
rl.webhooks.retry_delivery(webhook_id, delivery_id)
|
|
225
|
+
except ReplyLayerError as err:
|
|
226
|
+
if err.code == "WEBHOOK_DISABLED":
|
|
227
|
+
# Resume the webhook (PATCH enabled=True) before retrying.
|
|
228
|
+
pass
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## Mailbox settings
|
|
232
|
+
|
|
233
|
+
Each mailbox carries a scanner policy and a PII delivery mode:
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
# Redact PII before delivering inbound bodies to the agent
|
|
237
|
+
rl.mailboxes.update(
|
|
238
|
+
mailbox["id"],
|
|
239
|
+
scanner_policy={"language_mode": "english_only"},
|
|
240
|
+
pii_mode="redacted",
|
|
241
|
+
)
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
`pii_mode` values:
|
|
245
|
+
- `"passthrough"` (default) — message reads return `body.content` as a plaintext display projection. Session-cookie dashboard callers can opt into sanitized HTML with `body_format=html`.
|
|
246
|
+
- `"redacted"` — `body.content` is plaintext with detected PII spans replaced by `<TYPE>` tags (e.g. `<EMAIL_ADDRESS>`, `<PHONE_NUMBER>`). Requires Starter tier or above; sandbox accounts get `403 TIER_LIMIT`.
|
|
247
|
+
|
|
248
|
+
`pii_mode="redacted"` also applies to outbound webhook payloads: `message.*` events have `sender`/`recipient`/`to` → `<EMAIL_ADDRESS>` and `subject` → `<REDACTED>` before signing. The HMAC covers the redacted body — `verify_webhook_signature` works without any client-side changes.
|
|
249
|
+
|
|
250
|
+
### Advanced PII config (Pro+)
|
|
251
|
+
|
|
252
|
+
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"`.
|
|
253
|
+
|
|
254
|
+
```python
|
|
255
|
+
# Per-detector toggle: show emails to the agent, keep everything else redacted.
|
|
256
|
+
rl.mailboxes.update(
|
|
257
|
+
mailbox["id"],
|
|
258
|
+
pii_mode="redacted",
|
|
259
|
+
pii_redaction_config={
|
|
260
|
+
"EMAIL_ADDRESS": {"redact": False},
|
|
261
|
+
},
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# partial_mask: render credit cards as ****-****-****-1111 (separators preserved).
|
|
265
|
+
# `keep_last` is 1-6; `mask_char` defaults to "*".
|
|
266
|
+
rl.mailboxes.update(
|
|
267
|
+
mailbox["id"],
|
|
268
|
+
pii_redaction_config={
|
|
269
|
+
"CREDIT_CARD": {
|
|
270
|
+
"redact": True,
|
|
271
|
+
"operator": {"kind": "partial_mask", "keep_last": 4},
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# hash_replace: <EMAIL_ADDRESS:a3f1b9c2>. Deterministic per account; opaque
|
|
277
|
+
# across accounts. Lets your agent dedupe without seeing raw values.
|
|
278
|
+
rl.mailboxes.update(
|
|
279
|
+
mailbox["id"],
|
|
280
|
+
pii_redaction_config={
|
|
281
|
+
"EMAIL_ADDRESS": {
|
|
282
|
+
"redact": True,
|
|
283
|
+
"operator": {"kind": "hash_replace"},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# Reset to platform default.
|
|
289
|
+
rl.mailboxes.update(mailbox["id"], pii_redaction_config={})
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**Tier gate.** Any non-default value (a `redact: False` entry OR an operator with `kind: "partial_mask"` or `kind: "hash_replace"`) requires the `pii_advanced_controls` feature (Pro+). Non-feature accounts can still PATCH `{}`, default-shape entries (`{"redact": True}`, `{"kind": "replace_with_type"}`).
|
|
293
|
+
|
|
294
|
+
**`partial_mask` whitelist.** `PERSON` and `EMAIL_ADDRESS` are rejected (422) — partial-masking a name produces nonsense; partial-masking an email is hard to do well in v1. Use `hash_replace` for those instead.
|
|
295
|
+
|
|
296
|
+
**Downgrade behavior.** If you configure non-default `pii_redaction_config` on Pro and then downgrade, the persisted JSONB stays on the row but the read-side IGNORES it. Reads fall back to platform default. Re-upgrading restores the config instantly. The dashboard surfaces a "Saved but inactive" banner in this state.
|
|
297
|
+
|
|
298
|
+
**Webhook scope-out.** Advanced PII config does NOT apply to webhook payload metadata. Webhook delivery continues to use `pii_mode` for envelope-level field redaction; per-detector and operator control is API read-side only.
|
|
299
|
+
|
|
300
|
+
The Python SDK ships static type hints for `PiiOperator` (a `Union` of `PiiReplaceWithTypeOperator`, `PiiPartialMaskOperator`, and `PiiHashReplaceOperator` TypedDicts) — so a config like `{"kind": "hash_replace", "keep_last": 4}` is caught by `mypy` / `pyright` at the SDK boundary, not just at the server's 422.
|
|
301
|
+
|
|
302
|
+
**Outbound PII send safety.** `ScannerPolicy.outbound_pii_policy` tunes send decisions for the local outbound PII scanner by type:
|
|
303
|
+
|
|
304
|
+
```python
|
|
305
|
+
rl.mailboxes.update(
|
|
306
|
+
mailbox["id"],
|
|
307
|
+
scanner_policy={
|
|
308
|
+
"outbound_pii_policy": {
|
|
309
|
+
"ssn": "quarantine",
|
|
310
|
+
"credit_card": "review",
|
|
311
|
+
"phone_number": "allow_with_warning",
|
|
312
|
+
},
|
|
313
|
+
"outbound_review_policy": {
|
|
314
|
+
"approval_note": "required_for_sensitive_pii",
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
)
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Supported actions are `"allow"`, `"allow_with_warning"`, `"review"`, `"quarantine"`, and `"block"`. `"review"` routes matching sends to Pending approval; enabling it requires both Pro+ outbound PII controls and the review queue feature. Relaxing below platform defaults requires Pro+ (`pii_advanced_controls`); default or stricter values are accepted on every tier. Outbound PII scan results include `pii_type` (`"ssn"`, `"credit_card"`, or `"phone_number"`) so clients can inspect which type drove the action.
|
|
321
|
+
|
|
322
|
+
Approval notes are optional by default. Set `outbound_review_policy.approval_note` to `"required_for_sensitive_pii"` when approvers must add a note before sending SSN or credit-card review holds.
|
|
323
|
+
|
|
324
|
+
### Agent Attachment Access
|
|
325
|
+
|
|
326
|
+
Effective attachment exposure now comes from the mailbox policy surface (`attachment_exposure_mode` plus `attachment_allowed_file_families`), not from the legacy `attachment_access_enabled` boolean alone. Admin keys, pre-scoping keys, and dashboard sessions still bypass the agent mailbox-policy gate. Agent-key download requests without an explicit raw-download policy return 403 `ATTACHMENT_ACCESS_DISABLED` — surfaced as `ReplyLayerError` with `.code == "ATTACHMENT_ACCESS_DISABLED"`:
|
|
327
|
+
|
|
328
|
+
```python
|
|
329
|
+
from replylayer import ReplyLayerError
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
rl.attachments.get_download_url(message_id, 0)
|
|
333
|
+
except ReplyLayerError as err:
|
|
334
|
+
if err.code == "ATTACHMENT_ACCESS_DISABLED":
|
|
335
|
+
# Admin can configure the mailbox attachment policy through the
|
|
336
|
+
# dashboard or POST /v1/mailboxes/:id/attachment-access.
|
|
337
|
+
...
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Explicit `raw_download_selected_types` enablement requires a Pro+ production account, session-cookie auth, and fresh TOTP/password re-auth, so Bearer-key SDK clients receive 403 `REAUTH_REQUIRES_SESSION` when they try to enable or widen approved downloads. The SDK can still read attachment policy state, disable raw downloads, set `metadata_only` / `derived_content`, and perform same-or-narrower writes on an already-explicit approved-download mailbox.
|
|
341
|
+
|
|
342
|
+
Images are a separately confirmed raw-download family. When `allowed_file_families` includes `"image"`, pass `accept_image_risk_version` matching the mailbox response's `current_image_risk_version` unless the mailbox already has current image-risk acceptance. A mailbox response reports image state with `image_raw_download_confirmed`, `attachment_image_access_accepted_at`, and `attachment_image_access_accepted_version`. Legacy wildcard rows and stale image acceptances do not grant raw image downloads.
|
|
343
|
+
|
|
344
|
+
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.
|
|
345
|
+
|
|
346
|
+
See ENDPOINTS.md for the full contract and known limitations.
|
|
347
|
+
|
|
348
|
+
### Recipient allowlist (mailbox containment)
|
|
349
|
+
|
|
350
|
+
A mailbox is in `blocklist` mode by default — the pre-send gate rejects `suppressed_addresses` hits and allows everyone else. Switching to `allowlist` mode restricts outbound to a pre-approved list; an agent (or a compromised API key) physically cannot email outside the list.
|
|
351
|
+
|
|
352
|
+
```python
|
|
353
|
+
# Populate the allowlist first. Admin-only — agent keys get 403 INSUFFICIENT_SCOPE.
|
|
354
|
+
rl.mailboxes.allowlist.add(mailbox["id"], email="partner@corp.com")
|
|
355
|
+
rl.mailboxes.allowlist.add_bulk(mailbox["id"], emails=["a@x.com", "b@y.com"])
|
|
356
|
+
|
|
357
|
+
# Flip the mode. Returns 400 ALLOWLIST_EMPTY if the list is empty unless
|
|
358
|
+
# force_empty=True is passed to acknowledge the lockout.
|
|
359
|
+
rl.mailboxes.set_recipient_policy(mailbox["id"], "allowlist")
|
|
360
|
+
|
|
361
|
+
# Sends to off-list recipients now 403 with code RECIPIENT_NOT_ON_ALLOWLIST.
|
|
362
|
+
# Blocklist still runs first — a recipient on the do-not-contact list is
|
|
363
|
+
# rejected 403 with code RECIPIENT_SUPPRESSED (details["reason"] == "suppressed").
|
|
364
|
+
|
|
365
|
+
# Deleting the last entry while in allowlist mode returns 409 ALLOWLIST_LAST_ENTRY;
|
|
366
|
+
# pass force_empty=True to acknowledge.
|
|
367
|
+
rl.mailboxes.allowlist.delete(mailbox["id"], "partner@corp.com", force_empty=True)
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
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.
|
|
371
|
+
|
|
372
|
+
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`.
|
|
373
|
+
|
|
374
|
+
### Domain entries (sprint 039)
|
|
375
|
+
|
|
376
|
+
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`.
|
|
377
|
+
|
|
378
|
+
```python
|
|
379
|
+
# Allow everyone at @partner.com.
|
|
380
|
+
rl.mailboxes.allowlist.add(mailbox["id"], email="@partner.com")
|
|
381
|
+
|
|
382
|
+
# Block a whole competitor domain.
|
|
383
|
+
rl.suppressions.add(email="@competitor.com")
|
|
384
|
+
|
|
385
|
+
# Bulk mix emails + domains.
|
|
386
|
+
bulk = rl.mailboxes.allowlist.add_bulk(
|
|
387
|
+
mailbox["id"],
|
|
388
|
+
emails=["alice@corp.com", "@partner.com", "not-an-email"],
|
|
389
|
+
)
|
|
390
|
+
# bulk["added"][0]["pattern_type"] == "email"
|
|
391
|
+
# bulk["added"][1]["pattern_type"] == "domain"
|
|
392
|
+
# bulk["invalid"][0] == {"email": "not-an-email", "reason": "invalid_format"}
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Responses expose `pattern_type: "email" | "domain"` on every add/list/delete/bulk-added row. Pre-0.5.0 servers omit the field.
|
|
396
|
+
|
|
397
|
+
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"`).
|
|
398
|
+
|
|
399
|
+
### Blocked attempts (migration 038)
|
|
400
|
+
|
|
401
|
+
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.
|
|
402
|
+
|
|
403
|
+
```python
|
|
404
|
+
# Aggregated top-N view — grouped by (recipient, actor_id).
|
|
405
|
+
# next_cursor is always None; the aggregate is top-N, not paginated.
|
|
406
|
+
result = rl.mailboxes.allowlist.list_blocked_attempts(mailbox["id"])
|
|
407
|
+
for a in result["attempts"]:
|
|
408
|
+
print(f"{a['recipient']} × {a['count']} (last: {a['last_attempted_at']})")
|
|
409
|
+
|
|
410
|
+
# "Blocked this week" — recency filter (1..365 days).
|
|
411
|
+
week = rl.mailboxes.allowlist.list_blocked_attempts(mailbox["id"], within_days=7)
|
|
412
|
+
|
|
413
|
+
# Raw per-attempt history for forensic drill-in. Paginates via tuple cursor.
|
|
414
|
+
raw = rl.mailboxes.allowlist.list_blocked_attempts(
|
|
415
|
+
mailbox["id"], aggregate=False, limit=100,
|
|
416
|
+
)
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Async parity is identical — `await rl.mailboxes.allowlist.list_blocked_attempts(...)`.
|
|
420
|
+
|
|
421
|
+
Webhook deliveries are deduped server-side to at most one per `(account, mailbox, recipient)` per 60 seconds — a looping agent produces one delivery, not hundreds, keeping your subscription below the 20-abandoned-deliveries auto-disable threshold. Full attempt history is always available via `list_blocked_attempts`.
|
|
422
|
+
|
|
423
|
+
The MCP tool `list_allowlist_blocked_attempts` exposes the same view to agents — read-only by design. There is no dismiss-attempt tool (the containment boundary would be moot if an agent could clear its own rejection history).
|
|
424
|
+
|
|
425
|
+
## Mailbox identifiers
|
|
426
|
+
|
|
427
|
+
Every SDK method that takes a `mailbox_id` argument accepts **either the mailbox's UUID or its name**. The server resolves names against the authenticated account's active mailboxes. `rl.messages.list("support-bot")` and `rl.messages.list("a1b2-…")` are equivalent.
|
|
428
|
+
|
|
429
|
+
## Pagination
|
|
430
|
+
|
|
431
|
+
List endpoints return a `Page` with `data`, `has_more`, and `cursor`:
|
|
432
|
+
|
|
433
|
+
```python
|
|
434
|
+
page = rl.messages.list("mailbox-id", limit=50)
|
|
435
|
+
print(page.data) # list of message dicts
|
|
436
|
+
print(page.has_more) # bool
|
|
437
|
+
print(page.cursor) # str | None
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Pass `auto_paginate=True` for an iterator:
|
|
441
|
+
|
|
442
|
+
```python
|
|
443
|
+
for msg in rl.messages.list("mailbox-id", auto_paginate=True):
|
|
444
|
+
print(msg["subject"])
|
|
445
|
+
|
|
446
|
+
# Async
|
|
447
|
+
async for msg in rl.messages.list("mailbox-id", auto_paginate=True):
|
|
448
|
+
print(msg["subject"])
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
## Error handling
|
|
452
|
+
|
|
453
|
+
```python
|
|
454
|
+
from replylayer import ReplyLayer
|
|
455
|
+
from replylayer.errors import NotFoundError, RateLimitError
|
|
456
|
+
|
|
457
|
+
try:
|
|
458
|
+
rl.messages.get("nonexistent")
|
|
459
|
+
except NotFoundError:
|
|
460
|
+
print("Message not found")
|
|
461
|
+
except RateLimitError as e:
|
|
462
|
+
print(f"Rate limited, retry after {e.retry_after}s")
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
Error classes: `ReplyLayerError` (base), `AuthenticationError` (401), `ForbiddenError` (403), `NotFoundError` (404), `ValidationError` (400/422), `RateLimitError` (429).
|
|
466
|
+
|
|
467
|
+
## Webhook signature verification
|
|
468
|
+
|
|
469
|
+
> For a full integration guide (event catalog, retry behavior, idempotency, security, troubleshooting), see [`docs/webhooks.md`](../../docs/webhooks.md).
|
|
470
|
+
|
|
471
|
+
```python
|
|
472
|
+
from replylayer import verify_webhook_signature
|
|
473
|
+
|
|
474
|
+
verify_webhook_signature(
|
|
475
|
+
payload=request.body,
|
|
476
|
+
signature=request.headers["x-replylayer-signature"],
|
|
477
|
+
secret="whsec_...",
|
|
478
|
+
tolerance=300, # optional, seconds (default 300)
|
|
479
|
+
)
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
## Context managers
|
|
483
|
+
|
|
484
|
+
Both clients support context managers to properly close connection pools:
|
|
485
|
+
|
|
486
|
+
```python
|
|
487
|
+
with ReplyLayer(api_key="...") as rl:
|
|
488
|
+
rl.messages.send(...)
|
|
489
|
+
# connections closed
|
|
490
|
+
|
|
491
|
+
async with AsyncReplyLayer(api_key="...") as rl:
|
|
492
|
+
await rl.messages.send(...)
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
## Requirements
|
|
496
|
+
|
|
497
|
+
- Python >= 3.10
|
|
498
|
+
- httpx >= 0.27
|
|
499
|
+
|
|
500
|
+
## License
|
|
501
|
+
|
|
502
|
+
MIT
|