npm.io
0.21.0 • Published 3d ago

@replylayer/sdk

Licence
MIT
Version
0.21.0
Deps
0
Size
319 kB
Vulns
0
Weekly
157

@replylayer/sdk

Official TypeScript SDK for ReplyLayer — secure email for AI agents.

Install

npm install @replylayer/sdk

Quick start

import { ReplyLayer } from '@replylayer/sdk';

const rl = new ReplyLayer({ apiKey: process.env.REPLYLAYER_API_KEY! });

// Create a mailbox
const mailbox = await rl.mailboxes.create({ name: 'support' });

// Send an email
const sent = await 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)
const { message } = await rl.messages.wait(mailbox.id);
if (message) {
  console.log(`Got reply from ${message.sender}: ${message.subject}`);
}

// Browse conversation threads
const page = await rl.threads.list(mailbox.id);
for (const thread of page.data) {
  console.log(`${thread.subject} (${thread.message_count} messages)`);
}

Constructor options

new ReplyLayer({
  apiKey: 'rl_live_...',                      // required
  baseUrl: 'https://api.replylayer.ai',       // default
  maxRetries: 3,                              // retries on 429/5xx (0 = fail-fast)
  timeout: 30_000,                            // ms per request
  maxRetryAfterMs: 4_000_000,                 // cap on honoring a 429 Retry-After (default ~67min)
  onRetry: (info) => {},                      // silent-by-default retry hook
});

Retry behavior

The client retries failed requests up to maxRetries 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).
  • Multipart uploads are never retried (a retry would re-send the body).
  • Long Retry-After values block up to maxRetryAfterMs (default ~67 minutes, sized to ride out hour-bucket rate limits for batch jobs). When a server Retry-After exceeds this cap, the SDK throws the RateLimitError rather than sleeping — it never clamps-and-retries into a still-limited window. Interactive callers should set a low cap (e.g. maxRetryAfterMs: 30_000).
  • maxRetries: 0 is fail-fast — no implicit retry of any kind. Recommended for interactive / agent contexts where a request should surface its error immediately. Branch on RateLimitError.retryAfter and retry intentionally.
  • onRetry is silent by default — the SDK never writes to stdout/stderr. Pass an onRetry(info) hook to log or meter retries; it receives { attempt, error, delayMs, method, path }, may be async (it's awaited), and a throwing/rejecting callback is swallowed so it can't break the retry.
  • A retry sleep is abort-aware: a signal passed to a request (e.g. messages.wait({ signal })) interrupts an in-flight Retry-After wait.

Resources

Resource Methods
rl.mailboxes create, list, delete, update, setRecipientPolicy
rl.mailboxes.allowlist list, add, addBulk, delete, listBlockedAttempts
rl.messages send, list, get, reply, wait, release, block
rl.drafts create, get, list, update, send, delete
rl.threads list, get
rl.attachments getDownloadUrl, getPreview, upload, getUpload, deleteUpload
rl.webhooks create, list, get, update, delete, rotateSecret, test, listDeliveries, retryDelivery
rl.recipients create, list, delete, resend
rl.suppressions list, delete
rl.apiKeys create, list, revoke, rotate*
rl.account getUsage, getQuota, getLinkScanningStatus, enableLinkScanning
rl.health check

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

const draft = await 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') {
  const result = await rl.drafts.send(draft.id);
  console.log(`Sent ${result.message_id}`);
}

The send/reply/draft-send response carries two additive, nullable fields that explain a held send inline (no second read call). result.scan is the vendor-neutral scanner verdict (ScanSummary); result.hold_context ({ trigger_source, summary_reasons } or null) 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).

By default drafts.send() is synchronous — it returns SendMessageResponse once the scanner verdict is known, with scan and hold_context inline. Pass { async: true } to use the optimistic-ack path (Prefer: respond-async202 queued_for_dispatch, AsyncSendAck); the scanner runs in the background worker, so scan and hold_context are absent on the 202. Poll messages.get(message_id) or listen for message.delivered / message.dispatch_failed webhooks to observe the terminal state. Requires OUTBOUND_ASYNC_DISPATCH_ENABLED=true on the server. (messages.wait() is a mailbox long-poll for new inbound mail, not a way to observe a specific outbound message by ID.)

The send endpoint returns 409 with distinct codes:

  • 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/HITL decision drove the hold, hold_context.
  • DRAFT_ALREADY_SENT — the draft was already sent (race or retry after success).
try {
  await rl.drafts.send(draft.id);
} catch (err) {
  if (err instanceof ReplyLayerError && err.code === 'DRAFT_REJECTED_BY_RESCAN') {
    console.error('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 array. 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 return 403 OUTBOUND_ATTACHMENTS_DISABLED.

// Phase 1 — stage the file (returns an opaque handle).
const handle = await rl.attachments.upload({
  mailboxId: 'support',
  file: await readFile('invoice.pdf'),   // Buffer/Uint8Array/Blob/ArrayBuffer
  filename: 'invoice.pdf',
  contentType: 'application/pdf',         // advisory — the server re-sniffs the bytes
});

// The content scan runs asynchronously. Poll until it leaves `pending`.
let status = handle.content_scan_status;          // 'pending' at upload time
while (status === 'pending') {
  await new Promise((r) => setTimeout(r, 1000));
  const polled = await rl.attachments.getUpload(handle.id);
  if ('status' in polled) break;                  // already consumed
  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.
const result = await 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.deleteUpload(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({ attachment_ids: null }) 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).

Recipient allowlist (mailbox containment)

By default a mailbox uses blocklist mode — the pre-send gate rejects recipients on suppressed_addresses and allows everyone else. Switching a mailbox to allowlist mode restricts outbound to a fixed list of recipients; an agent (or a compromised API key) physically cannot email outside the list.

// Populate the allowlist first. Admin-only — agent keys get 403 INSUFFICIENT_SCOPE.
await rl.mailboxes.allowlist.add(mailbox.id, { email: 'partner@corp.com' });
await rl.mailboxes.allowlist.addBulk(mailbox.id, { emails: ['a@x.com', 'b@y.com'] });

// Flip the mode. Server returns 400 ALLOWLIST_EMPTY if the list is empty
// unless you pass forceEmpty: true to acknowledge the lockout.
await rl.mailboxes.setRecipientPolicy(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 forceEmpty: true to acknowledge.
await rl.mailboxes.allowlist.delete(mailbox.id, 'partner@corp.com', { forceEmpty: true });

A send/reply/draft-send to a recipient on your do-not-contact (suppression) list raises ReplyLayerError with err.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 send permission to an LLM would defeat the containment boundary. Agents can list (so they can see what they're allowed to email) but not add/addBulk/delete. Three new webhook events emit on change: recipient_allowlist.added, recipient_allowlist.removed, mailbox.recipient_policy_changed.

Domain entries (sprint 039)

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 — one entry covers every address there.
await rl.mailboxes.allowlist.add(mailbox.id, { email: '@partner.com' });

// Block a whole competitor domain.
await rl.suppressions.add({ email: '@competitor.com' });

// Bulk mix emails + domains; partial-success buckets handle invalids.
const bulk = await rl.mailboxes.allowlist.addBulk(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. The field is optional on the type — pre-0.5.0 servers omit it.

Blocklist precedence still holds: a domain-block beats an exact-allow at the same domain. Malformed patterns (@, @.com, @foo, @corp-.com, non-ASCII) return 400 INVALID_EMAIL (message: "Invalid email or domain pattern").

Blocked attempts (migration 038)

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 null for aggregated; the view is top-N, not paginated.
const { attempts } = await rl.mailboxes.allowlist.listBlockedAttempts(mailbox.id);
for (const a of attempts) {
  console.log(`${a.recipient} × ${a.count} (last: ${a.last_attempted_at})`);
}

// "Blocked this week" — same aggregate with a recency filter (1..365 days).
const week = await rl.mailboxes.allowlist.listBlockedAttempts(mailbox.id, {
  withinDays: 7,
});

// Raw per-attempt history for forensic drill-in. Paginates via tuple cursor.
const raw = await rl.mailboxes.allowlist.listBlockedAttempts(mailbox.id, {
  aggregate: false,
  limit: 100,
});

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

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

Delivery history & manual retry

rl.webhooks.listDeliveries(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.

let page = await rl.webhooks.listDeliveries(webhookId, { limit: 50 });
while (page.has_more) {
  page = await rl.webhooks.listDeliveries(webhookId, {
    limit: 50,
    before_at: page.next_before_at,
    before_id: page.next_before_id,
  });
}

rl.webhooks.retryDelivery(webhookId, deliveryId) 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:

try {
  await rl.webhooks.retryDelivery(webhookId, deliveryId);
} catch (err) {
  if (err instanceof ReplyLayerError && err.code === 'WEBHOOK_DISABLED') {
    // Resume the webhook (PATCH enabled=true) before retrying.
  }
}

Mailbox settings

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

// Redact PII before delivering inbound bodies to the agent
await 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 the plaintext display projection; dashboard session callers may opt into sanitized 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 — verifyWebhookSignature works without any client-side changes.

Advanced PII config (Pro+)

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

// Per-detector toggle: show emails to the agent, keep everything else redacted.
await 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 '*'.
await 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.
await rl.mailboxes.update(mailbox.id, {
  pii_redaction_config: {
    EMAIL_ADDRESS: {
      redact: true,
      operator: { kind: 'hash_replace' },
    },
  },
});

// Reset to platform default.
await 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 {}, null, or 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. Hosted webhook docs are coming.

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

await 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' | '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

Each mailbox has a per-mailbox gate (attachment_access_enabled, default off) that controls whether agent-role API keys can download attachment bytes via the attachment endpoint. Admin keys, pre-scoping keys, and dashboard sessions always bypass. When the flag is off for a mailbox, agent-key requests return 403 ATTACHMENT_ACCESS_DISABLED — surfaced as ReplyLayerError with .code === 'ATTACHMENT_ACCESS_DISABLED':

try {
  await rl.attachments.getDownloadUrl(messageId, 0);
} catch (err) {
  if (err instanceof ReplyLayerError && err.code === 'ATTACHMENT_ACCESS_DISABLED') {
    // Admin can enable via the dashboard (Mailbox settings → Agent Attachment Access)
    // or via POST /v1/mailboxes/:id/attachment-access (requires accept_disclaimer_version).
  }
}

Why it exists: ReplyLayer scans attachment bytes (virus/AV, MIME mismatch, policy) but does NOT extract text from raw downloads for prompt-injection scanning. Images are a separate raw-download family because prompt-injection text can be embedded in pixels and later exposed by OCR, vision models, or multimodal LLMs. Including image requires accept_image_risk_version unless the mailbox already has current image-risk acceptance. See ENDPOINTS.md -> Mailboxes for the full policy contract.

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 ENDPOINTS.md for the full contract and known limitations.

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

const status = await rl.account.getLinkScanningStatus();
// { active, accepted_version, current_version, privacy_ok }

if (!status.active && status.privacy_ok) {
  // current_version is the disclaimer you are acknowledging
  const res = await rl.account.enableLinkScanning({ accept_web_risk_version: status.current_version });
  // res.url_reputation.active === true; res.disclosure.notice / res.disclosure.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 (enableLinkScanning would otherwise fail with a 409 PRIVACY_VERSION_TOO_OLD).

Mailbox identifiers

Every SDK method that takes a mailboxId 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<T> with data, hasMore, and cursor:

const page = await rl.messages.list(mailboxId, { limit: 50 });
console.log(page.data);     // MessageSummary[]
console.log(page.hasMore);  // boolean
console.log(page.cursor);   // string | undefined

Pass autoPaginate: true for an async iterator:

for await (const msg of rl.messages.list(mailboxId, { autoPaginate: true })) {
  console.log(msg.subject);
}

Error handling

import { ReplyLayer, RateLimitError, NotFoundError } from '@replylayer/sdk';

try {
  await rl.messages.get('nonexistent');
} catch (err) {
  if (err instanceof NotFoundError) {
    console.log('Message not found');
  } else if (err instanceof RateLimitError) {
    console.log(`Rate limited, retry after ${err.retryAfter}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 the hosted webhook docs (coming).

Verify inbound webhook signatures from ReplyLayer:

import { verifyWebhookSignature } from '@replylayer/sdk';

const isValid = verifyWebhookSignature(
  requestBody,                           // raw body string
  request.headers['x-replylayer-signature'],
  process.env.WEBHOOK_SIGNING_SECRET!,
  { tolerance: 300 },                    // optional, seconds (default 300)
);

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

const payload = JSON.parse(requestBody);
// payload.event is the discriminator — NOT payload.type
if (payload.event === 'message.received') {
  // handle inbound message
} else if (payload.event === 'message.dispatch_failed') {
  // handle failed outbound send
}

Requirements

  • Node.js >= 20
  • No external dependencies (uses native fetch)

License

MIT

Keywords