Metadata-Version: 2.4
Name: replylayer
Version: 0.23.0
Summary: Official Python SDK for ReplyLayer — email for AI agents
Project-URL: Homepage, https://replylayer.ai
Project-URL: Repository, https://github.com/replylayer/rly
Project-URL: Issues, https://github.com/replylayer/rly/issues
License-Expression: MIT
Keywords: agent,ai,email,mailbox,replylayer,sdk,webhook
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Requires-Dist: typing-extensions>=4.0
Provides-Extra: cli
Requires-Dist: rly>=0.6.3; extra == 'cli'
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Description-Content-Type: text/markdown

# replylayer

Official Python SDK for [ReplyLayer](https://replylayer.ai) — secure email for AI agents.

> **Looking for the command-line tool?** This package is the SDK *library* (`import replylayer`). For the `rly` / `replylayer` CLI, install [`rly`](https://pypi.org/project/rly/) instead: `pipx install rly`.

## Install

```bash
pip install replylayer
```

## Quick start

```python
from replylayer import ReplyLayer

rl = ReplyLayer(api_key="rly_live_k3m9p2qx7vn4hjd0.uZ8Qb1vK3mN0pR7sT2wX9yA4cF6gH8jL1nP3rT5vW7z")

# Create a mailbox
mailbox = rl.mailboxes.create(name="support")

# Send an email
sent = rl.messages.send(
    from_mailbox=mailbox["name"],
    to="user@example.com",
    subject="Hello from my agent",
    body="Hi there!",
)

# Wait for a reply (long-poll, up to 30s)
result = rl.messages.wait(mailbox["id"])
if result["message"]:
    msg = result["message"]
    print(f"Got reply from {msg['sender']}: {msg['subject']}")

# Browse conversation threads
page = rl.threads.list(mailbox["id"])
for thread in page.data:
    print(f"{thread['subject']} ({thread['message_count']} messages)")
```

## Async usage

```python
from replylayer import AsyncReplyLayer

async with AsyncReplyLayer(api_key="rly_live_k3m9p2qx7vn4hjd0.uZ8Qb1vK3mN0pR7sT2wX9yA4cF6gH8jL1nP3rT5vW7z") as rl:
    mailbox = await rl.mailboxes.create(name="support")
    sent = await rl.messages.send(
        from_mailbox=mailbox["name"],
        to="user@example.com",
        subject="Hello",
        body="Hi!",
    )
```

## Constructor options

```python
ReplyLayer(
    api_key="rly_live_k3m9p2qx7vn4hjd0.uZ8Qb1vK3mN0pR7sT2wX9yA4cF6gH8jL1nP3rT5vW7z",  # required
    base_url="https://api.replylayer.ai",       # default
    max_retries=3,                              # retries on 429/5xx (0 = fail-fast)
    timeout=30.0,                               # seconds per request
    max_retry_after_seconds=4000.0,             # cap on honoring a 429 Retry-After (~67min)
    on_retry=None,                              # silent-by-default retry hook
)
```

## Retry behavior

The client retries failed requests up to `max_retries` times (default `3`). The
contract — read it before relying on retries:

- **`429` is retried on *every* method, including mutating ones** (`POST` /
  `PATCH` / `DELETE`). A `429` is a pre-dispatch rate-limit rejection, so nothing
  happened server-side — retrying is safe. The wait honors the `Retry-After`
  header.
- **`5xx` is retried only on non-mutating (`GET`) requests.** A `5xx` on a
  `POST` / `PATCH` / `DELETE` is **not** retried — the request may have executed,
  so a retry risks a double-send (or, for `DELETE`, retrying a lost-but-applied
  delete into a confusing `404`). To retry a `send` / `reply` safely, pass an
  idempotency key — see [Idempotent sends](#idempotent-sends).
- **Multipart uploads are never retried** (a retry would re-send the body).
- **Long `Retry-After` values block up to `max_retry_after_seconds`** (default
  ~67 minutes, sized to ride out hour-bucket rate limits for batch jobs). When a
  server `Retry-After` *exceeds* this cap, the SDK **raises the `RateLimitError`**
  rather than sleeping — it never clamps-and-retries into a still-limited window.
  Interactive callers should set a low cap (e.g. `max_retry_after_seconds=30`).
- **`max_retries=0` is fail-fast** — no implicit retry of any kind. Recommended
  for interactive / agent contexts. Branch on `RateLimitError.retry_after`.
- **`on_retry` is silent by default** — the SDK never writes to stdout/stderr.
  Pass an `on_retry(info)` hook to log or meter retries; it receives a `RetryInfo`
  (`attempt`, `error`, `delay_seconds`, `method`, `path`). On the async client it
  may be a coroutine (it's awaited); a raising callback is swallowed so it can't
  break the retry.

## Idempotent sends

Because a `5xx` on a send is **not** auto-retried (a blind retry risks a second
delivery + a second charge), the SDK gives you a way to retry it yourself
*safely*. `messages.send`, `messages.reply`, and scheduled sends via
`drafts.create` (with `send_at`) accept an `idempotency_key`: a network-retried
request carrying the same key produces **at most one** message and one charge —
the server replays the original outcome and returns the **same `message_id`**
instead of sending again.

```python
import uuid

key = str(uuid.uuid4())  # stable per send intent — persist it with the job

# First call sends; a same-key retry replays the first result (no second send).
sent = rl.messages.send(
    from_mailbox="support",
    to="user@example.com",
    subject="Hi",
    body="Hello",
    idempotency_key=key,
)
print(sent["message_id"])  # a same-key retry returns this SAME id
```

The key travels as the `Idempotency-Key` request header and is permanent (no
expiry). A non-throwing probe, `rl.messages.get_idempotency_replay(key)`, reports
whether a key already produced a result, is still in flight, or is a miss — call
it before a retry whose local inputs (a staged attachment, the original message)
may no longer be available. The async client exposes the same methods.

## Resources

| Resource | Methods |
|----------|---------|
| `rl.domains` | `create`, `list`, `get`, `verify`, `update_self_hosted_config`, `delete`, `set_default`, `recheck` |
| `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` |
| `rl.mailboxes.allowlist` | `list`, `add`, `add_bulk`, `delete`, `list_blocked_attempts` |
| `rl.mailboxes.inbound_allowlist` | `list`, `add`, `add_bulk`, `delete`, `list_blocked_attempts` |
| `rl.messages` | `send`, `list`, `get`, `reply`, `get_idempotency_replay`, `wait`, `release`, `block`, `report`, `delete`, `firewall_release`, `mark_read`, `set_starred`, `approve_review`, `deny_review` |
| `rl.drafts` | `create`, `get`, `list`, `update`, `send`, `delete` |
| `rl.threads` | `list`, `get`, `mark_read`, `set_starred` |
| `rl.attachments` | `get_download_url`, `get_preview`, `upload`, `get_upload`, `delete_upload` |
| `rl.webhooks` | `create`, `list`, `get`, `update`, `delete`, `rotate_secret`, `test`, `list_deliveries`, `retry_delivery` |
| `rl.recipients` | `create`, `list`, `delete`, `resend` |
| `rl.suppressions` | `list`, `add`, `add_bulk`, `delete` |
| `rl.inbound_blocklist` | `list`, `add`, `add_bulk`, `delete` |
| `rl.api_keys` | `create`, `list`, `revoke`, `rotate`* |
| `rl.account` | `get_usage`, `get_quota`, `get_link_scanning_status`, `enable_link_scanning`, `export` |
| `rl.legal_holds` | `apply`, `release`, `list`, `get` |
| `rl.health` | `check` |

*`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.

## Drafts: scan-then-review-then-send

`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.

```python
draft = rl.drafts.create(
    mailbox_id=mailbox["name"],
    to="user@example.com",
    subject="Re: your invoice",
    body="Thanks for your question.",
)
if draft["worst_decision"] == "allow":
    result = rl.drafts.send(draft["id"])
    print(f"Sent {result['message_id']}")
```

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`).

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.)

The send endpoint raises `ReplyLayerError` with distinct `.code` values on 409:
- `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`.
- `DRAFT_ALREADY_SENT` — the draft was already sent (race or retry after success).

```python
from replylayer import ReplyLayerError

try:
    rl.drafts.send(draft["id"])
except ReplyLayerError as err:
    if err.code == "DRAFT_REJECTED_BY_RESCAN":
        print("Rescan blocked it:", err.details)
```

## Outbound attachments (Pro+)

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** by a human account owner in the dashboard (Pro+, mailbox Settings page, TOTP/password re-auth). Once enabled, API keys can send attachments; uploads to a non-enabled mailbox raise `ForbiddenError` with `code="OUTBOUND_ATTACHMENTS_DISABLED"`.

```python
import time

# Phase 1 — stage the file (returns an opaque handle).
with open("invoice.pdf", "rb") as f:
    handle = rl.attachments.upload(
        mailbox_id="support",
        file=f.read(),                  # bytes or a file-like object
        filename="invoice.pdf",
        content_type="application/pdf",  # advisory — the server re-sniffs the bytes
    )

# The content scan runs asynchronously. Poll until it leaves "pending".
status = handle["content_scan_status"]   # "pending" at upload time
while status == "pending":
    time.sleep(1)
    polled = rl.attachments.get_upload(handle["id"])
    if polled.get("status") == "consumed":
        break
    status = polled["content_scan_status"]

# Phase 2 — reference the handle on a send. "clean" and "flagged" both send
# (a "flagged" finding flows to the message verdict, like a body finding);
# "error" is fail-closed.
result = rl.messages.send(
    from_mailbox="support",
    to="user@example.com",
    subject="Your invoice",
    body="Attached.",
    attachment_ids=[handle["id"]],
)
```

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).

## Delivery history & manual retry

`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.

```python
page = rl.webhooks.list_deliveries(webhook_id, limit=50)
while page["has_more"]:
    page = rl.webhooks.list_deliveries(
        webhook_id,
        limit=50,
        before_at=page["next_before_at"],
        before_id=page["next_before_id"],
    )
```

`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`:

```python
from replylayer import ReplyLayer, ReplyLayerError

try:
    rl.webhooks.retry_delivery(webhook_id, delivery_id)
except ReplyLayerError as err:
    if err.code == "WEBHOOK_DISABLED":
        # Resume the webhook (PATCH enabled=True) before retrying.
        pass
```

## Mailbox settings

Each mailbox carries a scanner policy and a PII delivery mode:

```python
# Redact PII before delivering inbound bodies to the agent
rl.mailboxes.update(
    mailbox["id"],
    scanner_policy={"language_mode": "english_only"},
    pii_mode="redacted",
)
```

`pii_mode` values:
- `"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`.
- `"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`.

`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.

### Advanced PII config (Pro+)

`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"`.

```python
# Per-detector toggle: show emails to the agent, keep everything else redacted.
rl.mailboxes.update(
    mailbox["id"],
    pii_mode="redacted",
    pii_redaction_config={
        "EMAIL_ADDRESS": {"redact": False},
    },
)

# partial_mask: render credit cards as ****-****-****-1111 (separators preserved).
# `keep_last` is 1-6; `mask_char` defaults to "*".
rl.mailboxes.update(
    mailbox["id"],
    pii_redaction_config={
        "CREDIT_CARD": {
            "redact": True,
            "operator": {"kind": "partial_mask", "keep_last": 4},
        },
    },
)

# hash_replace: <EMAIL_ADDRESS:a3f1b9c2>. Deterministic per account; opaque
# across accounts. Lets your agent dedupe without seeing raw values.
rl.mailboxes.update(
    mailbox["id"],
    pii_redaction_config={
        "EMAIL_ADDRESS": {
            "redact": True,
            "operator": {"kind": "hash_replace"},
        },
    },
)

# Reset to platform default.
rl.mailboxes.update(mailbox["id"], pii_redaction_config={})
```

**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"}`).

**`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.

**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.

**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.

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.

**Outbound PII send safety.** `ScannerPolicy.outbound_pii_policy` tunes send decisions for the local outbound PII scanner by type:

```python
rl.mailboxes.update(
    mailbox["id"],
    scanner_policy={
        "outbound_pii_policy": {
            "ssn": "quarantine",
            "credit_card": "review",
            "phone_number": "allow_with_warning",
        },
        "outbound_review_policy": {
            "approval_note": "required_for_sensitive_pii",
        },
    },
)
```

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.

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.

### Agent Attachment Access

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"`:

```python
from replylayer import ReplyLayerError

try:
    rl.attachments.get_download_url(message_id, 0)
except ReplyLayerError as err:
    if err.code == "ATTACHMENT_ACCESS_DISABLED":
        # Admin can configure the mailbox attachment policy through the
        # dashboard or POST /v1/mailboxes/:id/attachment-access.
        ...
```

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.

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.

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.

See the Mailboxes API reference at https://replylayer.ai/docs/api/mailboxes for the full contract and known limitations.

### Recipient allowlist (mailbox containment)

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.

```python
# Populate the allowlist first. Admin-only — agent keys get 403 INSUFFICIENT_SCOPE.
rl.mailboxes.allowlist.add(mailbox["id"], email="partner@corp.com")
rl.mailboxes.allowlist.add_bulk(mailbox["id"], emails=["a@x.com", "b@y.com"])

# Flip the mode. Returns 400 ALLOWLIST_EMPTY if the list is empty unless
# force_empty=True is passed to acknowledge the lockout.
rl.mailboxes.set_recipient_policy(mailbox["id"], "allowlist")

# Sends to off-list recipients now 403 with code RECIPIENT_NOT_ON_ALLOWLIST.
# Blocklist still runs first — a recipient on the do-not-contact list is
# rejected 403 with code RECIPIENT_SUPPRESSED (details["reason"] == "suppressed").

# Deleting the last entry while in allowlist mode returns 409 ALLOWLIST_LAST_ENTRY;
# pass force_empty=True to acknowledge.
rl.mailboxes.allowlist.delete(mailbox["id"], "partner@corp.com", force_empty=True)
```

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.

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`.

### Domain entries

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`.

```python
# Allow everyone at @partner.com.
rl.mailboxes.allowlist.add(mailbox["id"], email="@partner.com")

# Block a whole competitor domain.
rl.suppressions.add(email="@competitor.com")

# Bulk mix emails + domains.
bulk = rl.mailboxes.allowlist.add_bulk(
    mailbox["id"],
    emails=["alice@corp.com", "@partner.com", "not-an-email"],
)
# bulk["added"][0]["pattern_type"] == "email"
# bulk["added"][1]["pattern_type"] == "domain"
# bulk["invalid"][0]                    == {"email": "not-an-email", "reason": "invalid_format"}
```

Responses expose `pattern_type: "email" | "domain"` on every add/list/delete/bulk-added row. Pre-0.5.0 servers omit the field.

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"`).

### Blocked attempts

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.

```python
# Aggregated top-N view — grouped by (recipient, actor_id).
# next_cursor is always None; the aggregate is top-N, not paginated.
result = rl.mailboxes.allowlist.list_blocked_attempts(mailbox["id"])
for a in result["attempts"]:
    print(f"{a['recipient']} × {a['count']} (last: {a['last_attempted_at']})")

# "Blocked this week" — recency filter (1..365 days).
week = rl.mailboxes.allowlist.list_blocked_attempts(mailbox["id"], within_days=7)

# Raw per-attempt history for forensic drill-in. Paginates via tuple cursor.
raw = rl.mailboxes.allowlist.list_blocked_attempts(
    mailbox["id"], aggregate=False, limit=100,
)
```

Async parity is identical — `await rl.mailboxes.allowlist.list_blocked_attempts(...)`.

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`.

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).

## Malicious link scanning (URL reputation)

Malicious link scanning checks inbound links against Google Web Risk (only SHA-256 hash-prefixes are sent — full URLs never leave the platform). It is off by default and opt-in per account. Enabling it is an **admin action** (it turns on an account-wide sub-processor data flow), so agent-scoped keys can read status but not enable it (they raise `ForbiddenError`).

Pass the version you are acknowledging explicitly; the SDK does not auto-fetch it, so the consent is recorded against a version your code chose:

```python
status = rl.account.get_link_scanning_status()
# {"active", "accepted_version", "current_version", "privacy_ok"}

if not status["active"] and status["privacy_ok"]:
    res = rl.account.enable_link_scanning(accept_web_risk_version=status["current_version"])
    # res["url_reputation"]["active"] is True; res["disclosure"]["notice"] / ["advisory_url"]
```

If `privacy_ok` is `False` the account's accepted privacy policy predates the disclosed sub-processor — re-accept the current Privacy Policy first (`enable_link_scanning` would otherwise raise a `409 PRIVACY_VERSION_TOO_OLD`). The async client exposes the same methods (`await rl.account.get_link_scanning_status()` / `await rl.account.enable_link_scanning(...)`).

## Trusted instruction sources

Every inbound message read carries `agent_safety_context` with `untrusted_content` `True` — the body is external data, not instructions for the agent to act on. Trusted instruction sources is an opt-in, **read-path-only** relaxation of that default for one specific, customer-designated, *verified* sender address per mailbox (address-grain only — there is no domain-wide grant). It does not change what a resulting send is allowed to do; the only related send-side control is a per-mailbox strict-recipient toggle, configured outside the SDK.

The relaxation only applies on a read when **every** layer is satisfied, entirely on the server side: the platform feature is enabled, the mailbox's instruction-trust mode is on, the designated sender's message passed Mailgun sender verification (`verified_aligned`), the message scanned clean and is `available`, and the reading API key is a `role="agent"` key with the per-key capability enabled. There is no client-side opt-in — the SDK does not send any header or constructor flag to request this; a message either qualifies under the operator's configuration or it doesn't, and the response reflects that automatically:

```python
result = rl.messages.wait(mailbox["id"])
msg = result["message"]
ctx = msg.get("agent_safety_context") if msg else None
if ctx and ctx.get("instruction_trust"):
    # Gate passed: guidance was REPLACED with trusted-instruction wording (the
    # agent may act on this verified sender's own explicit request in this
    # message). untrusted_content is still True — this is metadata, not a
    # content-safety judgment.
    print(ctx["guidance"])
    print(ctx["instruction_trust"])
    # {"version": "v1", "match": "address", "verified_domain": ..., "verdict": "verified_aligned", "provenance": "managed"}
```

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.

## Mailbox identifiers

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.

## Pagination

List endpoints return a `Page` with `data`, `has_more`, and `cursor`:

```python
page = rl.messages.list("mailbox-id", limit=50)
print(page.data)      # list of message dicts
print(page.has_more)  # bool
print(page.cursor)    # str | None
```

Pass `auto_paginate=True` for an iterator:

```python
for msg in rl.messages.list("mailbox-id", auto_paginate=True):
    print(msg["subject"])

# Async
async for msg in rl.messages.list("mailbox-id", auto_paginate=True):
    print(msg["subject"])
```

## Error handling

```python
from replylayer import ReplyLayer
from replylayer.errors import NotFoundError, RateLimitError

try:
    rl.messages.get("nonexistent")
except NotFoundError:
    print("Message not found")
except RateLimitError as e:
    print(f"Rate limited, retry after {e.retry_after}s")
```

Error classes: `ReplyLayerError` (base), `AuthenticationError` (401), `ForbiddenError` (403), `NotFoundError` (404), `ValidationError` (400/422), `RateLimitError` (429).

## Webhook signature verification

> For a full integration guide (event catalog, retry behavior, idempotency, security, troubleshooting), see https://replylayer.ai/docs/webhooks.

```python
from replylayer import verify_webhook_signature

verify_webhook_signature(
    payload=request.body,
    signature=request.headers["x-replylayer-signature"],
    secret="whsec_...",
    tolerance=300,  # optional, seconds (default 300)
)
```

Once verified, parse and dispatch on the event type. **The discriminator field is `event`, not `type`:**

```python
import json

payload = json.loads(request.body)
# payload["event"] is the discriminator — NOT payload["type"]
if payload["event"] == "message.received":
    print("handle inbound message")
elif payload["event"] == "message.dispatch_failed":
    print("handle failed outbound send")
```

## Context managers

Both clients support context managers to properly close connection pools:

```python
with ReplyLayer(api_key="...") as rl:
    rl.messages.send(...)
# connections closed

async with AsyncReplyLayer(api_key="...") as rl:
    await rl.messages.send(...)
```

## Requirements

- Python >= 3.10
- httpx >= 0.27

## License

MIT
