@zenderock/useinbox-sdk
v0.3.1
Published
Email inboxes for AI agents — send, receive and reply in threads from code.
Maintainers
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-sdkAI 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 --helpNode.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 usageThese 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 falseThe 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.
textandhtmlhave 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
securityobject 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 reasonllm_skipped_capand 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 nomessage.receiveduntil a person releases it from the dashboard. Subscribe tomessage.quarantinedto be alerted. A message stopped by one of your own block rules is not announced: it is stored asblockedand stays visible inquarantine.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
blockedwith reasonsender_blockedand never reaches threads. It fires no webhook — neithermessage.receivednormessage.quarantined— but stays listed byquarantine.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.comforclient.com; a mailing service's own bounce domain does not count, nor does a DKIM pass alone). Otherwise it is held assender_unverified; a DMARC failure is held asdmarc_fail. - Outbound allow: once an inbox has one,
send,replyandforwardto any other address fail withRECIPIENT_NOT_ALLOWEDbefore anything is sent;error.rejectedRecipientslists the refused addresses. - Rules match the message's
Fromaddress, 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:writescope and withoutmessages:sendcan prepare drafts on any inbox but never send: on adirectinbox,send/reply/forwardfail with 403. inboxes.update(id, { sendPolicy: 'review' })works with a key; switching back todirectis refused (403) and done from the dashboard.- Webhooks
draft.created,draft.approved(with the sentmessage_id) anddraft.rejected(with the reviewer'sreason) 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 needwebhooks: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(ormessage.quarantined), after the inbound filter. It never fires the forwarding eventemail.received. - A key limited to selected inboxes cannot create or delete inboxes:
error.isKeyRestricted, witherror.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+.
