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.
Files changed (38) hide show
  1. replylayer-0.14.0/.gitignore +54 -0
  2. replylayer-0.14.0/PKG-INFO +502 -0
  3. replylayer-0.14.0/README.md +487 -0
  4. replylayer-0.14.0/pyproject.toml +31 -0
  5. replylayer-0.14.0/replylayer/__init__.py +143 -0
  6. replylayer-0.14.0/replylayer/_client.py +128 -0
  7. replylayer-0.14.0/replylayer/_http.py +326 -0
  8. replylayer-0.14.0/replylayer/_pagination.py +34 -0
  9. replylayer-0.14.0/replylayer/errors.py +152 -0
  10. replylayer-0.14.0/replylayer/py.typed +0 -0
  11. replylayer-0.14.0/replylayer/resources/__init__.py +0 -0
  12. replylayer-0.14.0/replylayer/resources/account.py +36 -0
  13. replylayer-0.14.0/replylayer/resources/api_keys.py +59 -0
  14. replylayer-0.14.0/replylayer/resources/attachments.py +115 -0
  15. replylayer-0.14.0/replylayer/resources/domains.py +79 -0
  16. replylayer-0.14.0/replylayer/resources/drafts.py +342 -0
  17. replylayer-0.14.0/replylayer/resources/health.py +21 -0
  18. replylayer-0.14.0/replylayer/resources/inbound_blocklist.py +93 -0
  19. replylayer-0.14.0/replylayer/resources/legal_holds.py +107 -0
  20. replylayer-0.14.0/replylayer/resources/mailboxes.py +768 -0
  21. replylayer-0.14.0/replylayer/resources/messages.py +425 -0
  22. replylayer-0.14.0/replylayer/resources/recipients.py +59 -0
  23. replylayer-0.14.0/replylayer/resources/suppressions.py +84 -0
  24. replylayer-0.14.0/replylayer/resources/threads.py +117 -0
  25. replylayer-0.14.0/replylayer/resources/webhooks.py +175 -0
  26. replylayer-0.14.0/replylayer/types.py +1578 -0
  27. replylayer-0.14.0/tests/__init__.py +0 -0
  28. replylayer-0.14.0/tests/test_async.py +429 -0
  29. replylayer-0.14.0/tests/test_attachments.py +354 -0
  30. replylayer-0.14.0/tests/test_client.py +46 -0
  31. replylayer-0.14.0/tests/test_domains.py +157 -0
  32. replylayer-0.14.0/tests/test_drafts.py +507 -0
  33. replylayer-0.14.0/tests/test_hitl_review_types.py +118 -0
  34. replylayer-0.14.0/tests/test_http.py +356 -0
  35. replylayer-0.14.0/tests/test_resources.py +522 -0
  36. replylayer-0.14.0/tests/test_web_risk_types.py +264 -0
  37. replylayer-0.14.0/tests/test_webhooks.py +213 -0
  38. 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