npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zenderock/useinbox-sdk

v0.3.1

Published

Email inboxes for AI agents — send, receive and reply in threads from code.

Readme

@zenderock/useinbox-sdk

Email inboxes for AI agents. Create a mailbox from code, read what arrives as conversations, and reply inside them.

npm install @zenderock/useinbox-sdk

AI coding agent? Read INTEGRATING.md, shipped in this package: which path to take, the API key scopes, reading and answering mail without duplicates, webhooks, and how to check the result. Online: https://www.useinbox.email/llms-full.txt.

Command-line client (0.3.1)

Version 0.3.1 includes the useinbox executable. Install it globally from npm:

npm install --global @zenderock/[email protected]
useinbox --help

Node.js 18 or newer is required. To use the CLI without a global installation, run npx --package=@zenderock/[email protected] useinbox --help.

Set USEINBOX_API_KEY in the environment, then run:

useinbox inboxes list
useinbox threads list INBOX_ID
useinbox threads read THREAD_ID
useinbox usage

These commands only read data. Successful output is JSON on stdout; errors are JSON on stderr with exit status 1. Use inboxes:read, messages:read or analytics:read for the corresponding operation and restrict inbox access where possible. No API key argument is accepted. The existing SDK API is unchanged.

Quick start

import { UseInbox } from '@zenderock/useinbox-sdk';

const client = new UseInbox({ apiKey: process.env.USEINBOX_API_KEY! });

// A live address, immediately able to receive mail.
const inbox = await client.inboxes.create({
  username: 'support',
  displayName: 'Acme Support',
  clientId: 'agent-7',
});
console.log(inbox.address); // [email protected]

// Answer everything unread.
for await (const thread of client.threads.iterate(inbox.id, { unreadOnly: true })) {
  const full = await client.threads.get(thread.id);
  const last = full.messages?.at(-1);

  await client.threads.reply(thread.id, {
    text: `Thanks for writing about "${thread.subject}". Looking into it.`,
  });

  await client.threads.markRead(thread.id);
  await client.threads.setLabels(thread.id, ['handled']);
}

Your own domain

Without domainId an inbox lives on the shared agent.useinbox.email sandbox, with a random suffix. Pass the id of one of your verified domains (Domains in the dashboard: copy it from the domain's page) and the address is exactly username@domain:

const inbox = await client.inboxes.create({
  username: 'support',
  domainId: 'b6c1…', // agents.acme.com, verified in the dashboard
});
console.log(inbox.address); // [email protected]
console.log(inbox.domain, inbox.is_sandbox); // agents.acme.com false

The domain must be Active and able to send; otherwise the call fails with DOMAIN_NOT_READY (error.isDomainNotReady). A name already used by an alias or another inbox fails with ADDRESS_TAKEN (error.isAddressTaken): no suffix is added on your own domain. Prefer a subdomain such as agents.acme.com, so what your agents send never weighs on your team's own mail reputation.

A domain that agent inboxes live on cannot be deleted until they are.

Why reply and not send

inboxes.send starts a new conversation. threads.reply answers an existing one and attaches the In-Reply-To and References headers that make the recipient's mail client show your message inside the original thread. Sending a fresh message with Re: in the subject does not do this — the recipient gets a second, unrelated conversation, and the next reply comes back detached from the first.

Threads and messages

Listing threads returns previews. Bodies and attachments come from threads.get(id), which is one call per conversation — deliberately, so a listing of a busy inbox does not fetch every stored body to render a list.

Every message in threads.get carries extracted_text beside text and html: the new content only, without the quoted history ("On … wrote:", Outlook "From: / Sent:" headers, > quotes, Gmail / Outlook / Apple Mail quote blocks) and without the signature. On a ten-message thread, reading extracted_text for earlier messages instead of text saves most of the tokens, since each message quotes the ones before it. It is never empty when the message has a body: when nothing can be told apart it is the whole text. Forwarded content is kept.

const full = await client.threads.get(thread.id);
const context = full.messages!.map((m, i, all) =>
  i === all.length - 1 ? m.text : m.extracted_text,
);

Attachments, copies and forwards

import { readFile } from 'node:fs/promises';

// Attach files as bytes (or a base64 string), and blind-copy an auditor.
await client.inboxes.send(inbox.id, {
  to: ['[email protected]'],
  bcc: ['[email protected]'],
  subject: 'Your invoice',
  text: 'Attached.',
  attachments: [
    { filename: 'invoice.pdf', contentType: 'application/pdf', content: await readFile('invoice.pdf') },
  ],
});

// Answer everyone on the last message: sender, To and Cc, minus this inbox.
await client.threads.reply(thread.id, { text: 'Thanks all.', replyAll: true });

// Download what arrived. `index` is the attachment's index in threads.get().
const message = (await client.threads.get(thread.id)).messages!.at(-1)!;
for (const attachment of message.attachments ?? []) {
  const file = await client.messages.downloadAttachment(message.id, attachment.index);
  // file.filename, file.contentType, file.data (Uint8Array)
}

// Or read a PDF, Word (DOCX) or text-like attachment as plain text.
const doc = await client.messages.attachmentText(message.id, 0, { maxPages: 20 });
// doc.text, doc.format ('pdf' | 'docx' | 'text' | 'html'), doc.truncated, doc.security

// Forward it on, files included (set includeAttachments: false to leave them).
await client.messages.forward(message.id, { to: ['[email protected]'], text: 'FYI' });

attachmentText reads attachments up to 20 MB, 50 PDF pages and 100,000 characters by default (maxPages up to 200, maxChars up to 500,000); past a limit it returns what it read with truncated: true. Other types (images, spreadsheets, archives) throw a UseInboxError with status 415; an encrypted or damaged document throws a 422. The text is sanitized like a message body — invisible PDF text, hidden Word runs and invisible characters are removed — and scanned: doc.security.status is review when it reads like a prompt injection.

Limits: 20 files, 10 MB each, 25 MB per message; 50 recipients across to, cc and bcc. A forward opens its own thread in the same inbox, so the forwardee's answer does not become the original thread's latest message.

Search

Both listings take q, which matches subjects, participants, labels, senders and message text, case-insensitively; every word must appear somewhere in the thread. It combines with labels and unreadOnly, keeps the latest-activity order and pages with cursor like any listing.

// One inbox.
const page = await client.threads.list(inbox.id, { q: 'refund 991' });

// Every inbox the key may read. A key restricted to some inboxes searches only those.
const all = await client.threads.listAll({ q: 'invoice', unreadOnly: true });

Inbound security

Whoever writes to an agent inbox is writing to your model. Every inbound message is filtered before your agent can read it:

  • Sanitized bodies. text and html have what a human could not see removed: hidden elements (display:none, zero font size, text the colour of its background...), HTML comments, zero-width and bidirectional control characters.
  • A security object on every message with the SPF, DKIM and DMARC verdicts, a 0-100 scan score and the reasons behind it (dmarc_fail, instruction_override, masked_link...).
  • AI review on paid plans. A message the rules let through is also read by an AI reviewer, which can hold it (llm_injection) but never release it; an outage of the reviewer holds nothing. It covers as many messages a month as your plan's monthly email allowance, then stops until the next month: those messages carry the informational reason llm_skipped_cap and are not held. It never runs on the Free plan.
  • Quarantine. A message that fails DMARC or scores 50 or more is held in review: it is absent from threads and fires no message.received until a person releases it from the dashboard. Subscribe to message.quarantined to be alerted. A message stopped by one of your own block rules is not announced: it is stored as blocked and stays visible in quarantine.list.
const full = await client.threads.get(thread.id);
for (const message of full.messages ?? []) {
  if (message.security?.reasons.length) {
    console.warn(message.id, message.security.reasons.map((r) => r.code));
  }
}

// What is held, and why. Never the content; release and block are dashboard-only.
const held = await client.quarantine.list({ inboxId: inbox.id });
for (const item of held.items) {
  console.log(item.from, item.security.reasons.map((r) => r.code));
}

Allow and block lists

Each inbox can name the senders it listens to and the recipients it may write to. A pattern is an exact address ([email protected]), a domain (client.com, that domain only) or every subdomain of one (*.client.com, not the domain itself).

// Never hand this sender's mail to the agent.
await client.inboxes.rules.block(inbox.id, '[email protected]', { note: 'phishing' });

// Only listen to clients: from now on, anyone else is held for a person.
await client.inboxes.rules.add(inbox.id, [
  { kind: 'allow', pattern: 'client.com' },
  { kind: 'allow', pattern: '*.client.com' },
]);

// Only ever write to clients.
await client.inboxes.rules.allow(inbox.id, 'client.com', { direction: 'outbound' });

const rules = await client.inboxes.rules.list(inbox.id);
await client.inboxes.rules.remove(inbox.id, rules[0].id);
  • Block (inbound) wins over everything: the message is stored as blocked with reason sender_blocked and never reaches threads. It fires no webhook — neither message.received nor message.quarantined — but stays listed by quarantine.list({ status: ['blocked'] }), where a person can release it.
  • Inbound allow: once an inbox has one, a sender matching none is held for review (sender_not_allowed) — held, never dropped. An allowed sender is not waved through: it still has to pass the filter and the content scan, and it counts as allowed only when the message is authenticated — a DMARC pass, or, for a domain that publishes no DMARC policy (dmarc: 'gray'), an SPF pass for an envelope sender in the same organizational domain as the From (bounce.client.com for client.com; a mailing service's own bounce domain does not count, nor does a DKIM pass alone). Otherwise it is held as sender_unverified; a DMARC failure is held as dmarc_fail.
  • Outbound allow: once an inbox has one, send, reply and forward to any other address fail with RECIPIENT_NOT_ALLOWED before anything is sent; error.rejectedRecipients lists the refused addresses.
  • Rules match the message's From address, the one a person sees and DMARC authenticates — not the envelope sender, which is a bounce address for most mailing services.

An API key can tighten, not loosen. With a key you can add block rules, start an allow list and remove allow rules that are not the last one. Removing a block rule, widening an existing allow list or removing its last rule fails with 403 (error.requiresDashboard): a person does it in the dashboard. An agent that read the wrong email cannot talk itself out of its own guard rails.

Up to 1,000 rules per inbox, 100 per call. Adding a rule that already exists returns it unchanged.

Errors

import { UseInboxError, UseInboxConnectionError } from '@zenderock/useinbox-sdk';

try {
  await client.inboxes.create();
} catch (error) {
  if (error instanceof UseInboxError && error.isQuotaExceeded) {
    // Plan limit reached — retrying will not help. Delete an unused inbox.
  } else if (error instanceof UseInboxError && error.isAddressTaken) {
    // On your own domain: that username is in use. Pick another.
  } else if (error instanceof UseInboxConnectionError) {
    // Acceptance is uncertain. Reconcile unkeyed mutations before trying again.
  }
}

Branch on error.code, not on error.message: codes are stable, messages are prose.

GET requests and calls that send or create with an explicit idempotency key retry transient failures (429, 5xx, connection errors), twice by default. Retry-After seconds and HTTP dates take precedence over exponential backoff with jitter. maxRetries is an integer from 0 to 10. Other mutations, including unkeyed creation, read markers, label updates and DELETE, are never retried automatically. Conflicts are not retried. Besides inboxes.send and threads.reply, inboxes.create, messages.forward, drafts.create, drafts.createReply and inboxes.rules.add accept { idempotencyKey } as their last argument, with the same contract.

Safe sends and pending confirmation

import { isPendingReview } from '@zenderock/useinbox-sdk';

// Persist this key and message in your job BEFORE starting the operation.
const operationKey = 'support-job-42-reply-1';
const result = await client.inboxes.send(inbox.id, {
  to: ['[email protected]'], text: 'Hello',
}, { idempotencyKey: operationKey });
// On an inbox whose send policy is review, nothing was sent: a draft waits.
if (!isPendingReview(result)) console.log(result.id, result.idempotentReplay);

await client.threads.reply(thread.id, { text: 'Following up' }, {
  idempotencyKey: 'support-job-42-reply-2',
});

Same operation = same key, route and exact body; new message = new key. Keys are scoped to the authenticated identity/API key and workspace. Never switch identity or workspace to retry a send. Reservations do not expire into a fresh send. Successful replays return the original message and idempotentReplay: true (the REST header is Idempotent-Replay: true; API errors expose the same flag).

IDEMPOTENCY_CONFLICT (409) means the body, route or authorization boundary changed, or the key belongs to a legacy reservation whose scope is unknown. IDEMPOTENCY_OUTCOME_UNKNOWN (409) means Pending confirmation: a concurrent operation may be running, the process may have stopped, or provider acceptance may precede a lost response. Repeating the same request can retrieve a completed result; it will never send again. Do not rotate the key or offer an unchecked “Retry send”. Reconcile provider/log evidence with an operator. This release provides no automatic provider reconciliation or safe resend of unknown outcomes. A provider timeout after acceptance cannot truthfully return a message ID it never received; the reservation remains blocked instead.

Pausing an inbox

await client.inboxes.pause(inbox.id);   // stops sending at once
await client.inboxes.resume(inbox.id);
await client.inboxes.update(inbox.id, { displayName: 'Acme Billing', metadata: { team: 'billing' } });

A paused inbox refuses send and reply with INBOX_PAUSED (error.isInboxPaused). It keeps receiving and storing mail, but fires no message.received webhook until it is resumed — list unread threads to catch up. inboxes.list() includes paused inboxes (is_active: false); pass { status: 'active' } or { status: 'paused' } to narrow it.

Human review

An inbox has a send policy. With direct (the default for inboxes created through the API) sends go out. With review, inboxes.send, threads.reply and messages.forward send nothing: they create a draft and resolve to { status: 'pending_review', draft }. A person approves or rejects it in the dashboard; that cannot be done with an API key.

import { isPendingReview } from '@zenderock/useinbox-sdk';

const inbox = await client.inboxes.create({ username: 'support', sendPolicy: 'review' });

const result = await client.threads.reply(thread.id, { text: 'Refund approved.' }, {
  idempotencyKey: 'refund-42-reply',
});
if (isPendingReview(result)) {
  console.log('waiting for a person:', result.draft.id);
} else {
  console.log('sent:', result.id);
}

// Prepare explicitly, whatever the policy, and follow the outcome.
const draft = await client.drafts.create(inbox.id, { to: ['[email protected]'], subject: 'Hi', text: 'Hello' });
await client.drafts.update(draft.id, { text: 'Hello Ada' }); // pending drafts only
const { status, sent_message_id, rejection_reason } = await client.drafts.get(draft.id);
const waiting = await client.drafts.list({ status: ['pending'] });
await client.drafts.delete(draft.id); // withdraw while pending
  • A key with the drafts:write scope and without messages:send can prepare drafts on any inbox but never send: on a direct inbox, send/reply/forward fail with 403.
  • inboxes.update(id, { sendPolicy: 'review' }) works with a key; switching back to direct is refused (403) and done from the dashboard.
  • Webhooks draft.created, draft.approved (with the sent message_id) and draft.rejected (with the reviewer's reason) carry no body or files.
  • An edited or deleted draft that a person already decided fails with DRAFT_NOT_PENDING (error.isDraftNotPending).

send, reply and forward are typed Promise<SendResult> (Message | PendingReviewResult); narrow with isPendingReview before reading message fields.

Webhooks and restricted keys

const hook = await client.webhooks.create({
  url: 'https://agent.example/hooks/useinbox',
  events: ['message.received', 'draft.approved', 'draft.rejected'],
  secret: process.env.WEBHOOK_SECRET,
});
hook.inbox_ids; // the inboxes it receives events for, when the key has some
await client.webhooks.test(hook.id);
  • Listing needs webhooks:read; creating, changing, testing and deleting need webhooks:manage (ApiKeyScope). A key with no scopes holds every scope.
  • A webhook created by a key limited to selected inboxes only receives agent inbox events (message.*, thread.created, draft.*, message.quarantined) of those inboxes (inbox_events_only, inbox_ids), and such a key only sees webhooks bound within its own inboxes.
  • Mail received by an agent inbox is announced by message.received (or message.quarantined), after the inbound filter. It never fires the forwarding event email.received.
  • A key limited to selected inboxes cannot create or delete inboxes: error.isKeyRestricted, with error.details.requires.

Options

| Option | Default | | |---|---|---| | apiKey | — | required | | baseUrl | https://api.useinbox.email | | | timeoutMs | 30000 | per attempt | | maxRetries | 2 | three attempts total | | fetch | global fetch | inject your own for tests |

Node 18+.