Skip to main content

Official Python SDK for ReplyLayer — email for AI agents

Project description

replylayer

Official Python SDK for ReplyLayer — 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 instead: pipx install rly.

Install

pip install replylayer

Quick start

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

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

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

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, update, 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
rl.simulator inject_inbound
rl.policy get_mailbox_policy, get_overview, get_account_policy, update_account_policy, preview_mailbox_policy

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

Simulator

Outbound scenarios use the normal send methods. Inbound scenarios use rl.simulator.inject_inbound():

outbound = rl.messages.send(
    from_mailbox=mailbox["name"],
    to="delivered+ci-run-42@simulator.replylayer.net",
    subject="simulator check",
    body="exercise the delivered path",
)

inbound = rl.simulator.inject_inbound({
    "mailbox_id": mailbox["id"],
    "scenario": "clean",
    "label": "ci-run-42",
})

# Branch on the result: available | quarantined | pending.
print(outbound["message_id"], inbound["status"], inbound.get("message_id"))

Outbound delivery, bounce, complaint, and suppression addresses; delayed webhook outcomes; inbound scenario semantics; and billing/suppression caveats are defined in the email simulator guide. One Sandbox account can run all four exact outbound scenarios in the same day; those addresses bypass destination-concentration controls but still consume normal daily/cumulative usage allowance.

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.

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", "review_causes"?, "agent_instructions"} or None) is the policy/human-review reason plus held-send guidance, non-None on held sends — a typed policy cause (first-contact/send-window/Supervised), a policy/human-review hold that changed the scanner's decision, or a genuine scan-explained hold such as a real scanner quarantine (trigger_source: mailbox_policy | scanner | both); it stays None on sent/terminal outcomes and normally on retryable infrastructure holds — a typed policy cause still attaches one (branch on email_effect["effect_status"] for those). review_causes is the typed hold-cause discriminator (content_warning | first_contact | send_window | mailbox_policy) driving a Supervised (risky_only) hold or a send-window promotion — a NotRequired key, absent on holds this dashboard policy builder didn't cause-type.

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

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.

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:

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:

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

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

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 your review queue (approve/deny) — available on every tier. 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.

Mailbox policy fields (dashboard policy builder)

rl.mailboxes.update() also accepts the per-mailbox fields the dashboard policy builder governs: agent_authoring_mode ("send_and_draft" | "draft_only" | "read_only"), hitl_mode (now widened to "disabled" | "all_outbound" | "risky_only"), approval_expiry ("24h" | "72h" | "7d" | "never"), and send_window (a dict binding AGENT-origin sends to a weekly window). apply_policy_mode applies one of the four named modes ("read_only" / "draft_only" / "supervised" / "trusted") atomically and is mutually exclusive with those raw identity fields in the same call (the server returns 400 AMBIGUOUS_POLICY_MODE_APPLICATION).

# Apply a named mode — writes agent_authoring_mode/hitl_mode/agent_send_policy together.
rl.mailboxes.update(mailbox["id"], apply_policy_mode="supervised")

# Or set fields directly (not combined with apply_policy_mode in the same call).
rl.mailboxes.update(
    mailbox["id"],
    agent_authoring_mode="draft_only",
    approval_expiry="72h",
    send_window={
        "timezone": "America/Chicago",
        "days": ["mon", "tue", "wed", "thu", "fri"],
        "start": "09:00",
        "end": "18:00",
        "outside_action": "require_approval",
    },
)

# send_window omitted (default) leaves it unchanged; pass send_window=None
# explicitly to CLEAR it (always-open — a loosening).
rl.mailboxes.update(mailbox["id"], send_window=None)

Direction gate. Tightening (toward read_only/shorter expiry/narrower window) works with an admin API key. Any loosening requires a dashboard session + fresh re-auth — a bearer key gets 403 REAUTH_REQUIRES_SESSION. rl.policy.get_mailbox_policy(mailbox_id) reads the derived policy_mode, last_applied_policy_mode, the calling key's binding["permitted_verbs"], and enforcement — the live rollout-lever state ({"risky_only": ..., "send_window": ...}, each "off" | "shadow" | "enforce"). A stored hitl_mode="risky_only" or send_window only actually holds a send when the matching enforcement field reads "enforce"; off/shadow mean the posture is saved but not yet active. rl.policy.get_overview() carries the same enforcement block once, account-wide (the levers are env-global, not per-mailbox). rl.policy.preview_mailbox_policy(mailbox_id, to=...) is a side-effect-free dry-run of the gate stack for a sample send.

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

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.

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

The same RECIPIENT_SUPPRESSED code also fires for a platform-scoped cross-account hard-bounce hit — an address that hard-bounced somewhere on the platform (not necessarily on your account), which ReplyLayer refuses on every ReplyLayer-managed sending domain to protect the shared reputation every customer rides on (never enforced on a delegated/BYOD or self-hosted domain). details["scope"] == "platform" distinguishes it from your own list (which omits scope); this variant isn't in GET /v1/suppressions and can't be removed via the SDK — it's an operator-only override.

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.

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

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

Recipient verification (send-path safety checks)

POST /v1/messages/send, .../reply, and POST /v1/drafts/:id/send run a permissive, fail-open recipient-quality check before dispatch. Only a confirmed violation rejects the send — an infrastructure hiccup (DNS blip, verification service outage) never blocks it. A reply or thread continuation is exempt (the recipient is a proven correspondent). Five ReplyLayerError codes, all HTTP 422:

Code Meaning
RECIPIENT_ADDRESS_INVALID The address fails a strict syntax check beyond the basic email-format validation.
RECIPIENT_DOMAIN_TYPO_SUSPECTED The domain looks like a single-character typo of a common consumer mail provider (e.g. gmial.com). The suggested domain rides in the exception message.
RECIPIENT_ROLE_ADDRESS The local part is a structural role/distribution mailbox (noreply@, no-reply@, etc. by default), not an individual inbox.
RECIPIENT_DISPOSABLE_ADDRESS The domain is a known disposable/temporary email provider.
RECIPIENT_UNDELIVERABLE The domain has no mail servers (no MX or A record) — mail to it would hard-bounce.
from replylayer import ValidationError

try:
    rl.messages.send(from_mailbox="support", to="noreply@example.com", subject="hi", body="x")
except ValidationError as err:
    if err.code == "RECIPIENT_ROLE_ADDRESS":
        print("That looks like a role mailbox, not a person — double-check the recipient.")

Mailbox creation & sending-domain provisioning

On paid accounts (once the per-account sending-domain estate is enabled), rl.mailboxes.create(...) can race the account's sending-domain setup. There are no new SDK methods — two error codes flow through the existing error envelope:

Code HTTP Meaning
DOMAIN_PROVISIONING_PENDING 409 The account's sending domain is still being set up (or a domain change is in flight). Retryable — err.details["retry_after"] carries the suggested seconds. Poll rl.domains.list() for the platform row's verification_status.
DOMAIN_PROVISIONING_FAILED 409 Sending-domain setup failed. Not retryable from the client — contact support.

The SDK deliberately does not auto-retry; retry loops live in the CLI (rly mailbox create waits up to 60s) and the dashboard.

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). New accounts have it enabled by default at signup (disclosed in Privacy Policy §7a; per-mailbox opt-out via the mailbox scanner policy); older accounts 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:

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 acknowledged privacy policy version predates the disclosed sub-processor — review and acknowledge 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:

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:

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:

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

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.

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:

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:

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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

replylayer-0.26.0.tar.gz (129.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

replylayer-0.26.0-py3-none-any.whl (82.6 kB view details)

Uploaded Python 3

File details

Details for the file replylayer-0.26.0.tar.gz.

File metadata

  • Download URL: replylayer-0.26.0.tar.gz
  • Upload date:
  • Size: 129.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for replylayer-0.26.0.tar.gz
Algorithm Hash digest
SHA256 8957708821a5507e3cfa5ce26ef97908034c01420ef88e2923e530e6f679c61d
MD5 d1e62a957dccb39a4b17cffc40138d01
BLAKE2b-256 3e8ee7c030abd2c8b03b98c84e07522a0c8bd5647cb81dcac4f0dd99ce0e850c

See more details on using hashes here.

Provenance

The following attestation bundles were made for replylayer-0.26.0.tar.gz:

Publisher: publish-pysdk.yml on replylayer/ReplyLayer

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file replylayer-0.26.0-py3-none-any.whl.

File metadata

  • Download URL: replylayer-0.26.0-py3-none-any.whl
  • Upload date:
  • Size: 82.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for replylayer-0.26.0-py3-none-any.whl
Algorithm Hash digest
SHA256 c7f9e0f6f1f120637984b3e24378bf0bf90a2e67b096f00e6b34a13febb6535c
MD5 b562dc810101db6b977f8cbf6d4047f4
BLAKE2b-256 6dab35393f2b7a40590c150e0b3eacbcfc0bc95fc2afa61148865dc8829e4cac

See more details on using hashes here.

Provenance

The following attestation bundles were made for replylayer-0.26.0-py3-none-any.whl:

Publisher: publish-pysdk.yml on replylayer/ReplyLayer

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page