# Inbox

> Give an agent its own email address. Mailboxes receive mail from anyone and send it, with threads, attachments, HTML, signed webhooks to a unit or a URL, Glove tools, and your own domain.

Web page: https://kuiper.sh/docs/inbox/

## An email address for your agent

A mailbox is an email address that belongs to a project. Mail sent to it arrives in Kuiper. Your agent reads it, answers it and writes new mail through the gateway. People read and answer it on the project's **Inbox** page. A webhook tells your agent the moment something arrives.

Addresses on Kuiper's domain are a prefix you choose, `+` and eight digits Kuiper adds:

```text
concierge+43223319@agent-inbox.kiprsh.app
```

Any number of projects can use the same prefix. Mail to `concierge+43223319+orders@agent-inbox.kiprsh.app` reaches the same mailbox, tagged `orders`. On [your own domain](#your-own-domain), the address is exactly the name you choose: `support@yourcompany.com`.

The API lives at `https://gateway.kuiper.sh/inbox` and takes a [gateway key](https://kuiper.sh/docs/gateway-keys.md) (`kgw_…`) that includes the `inbox` service.

## Make a mailbox

In the dashboard, open the project's **Inbox** and choose **New mailbox**. Or:

```sh
kuiper inbox create concierge -p support --display-name Concierge
```

It prints the address, and the webhook secret if you gave a webhook. The secret is shown once.

## From a unit on Kuiper

Point your variables at the built-in `inbox` resource, under names of your choosing. Each running copy of the unit gets its own key:

| Output | Value |
| --- | --- |
| `${{inbox.BASE_URL}}` | `https://gateway[.<region>].kuiper.sh/inbox` |
| `${{inbox.API_KEY}}` | The running copy's key (secret) |

The key opens only the services the unit's variables reference, so a unit that also calls models references `${{models.…}}` too. [Example: an email agent](https://kuiper.sh/docs/email-agent.md) deploys a unit that answers its mail, step by step.

From anywhere else, make a key with `kuiper keys create NAME --services inbox`.

## With Kuiper's SDK

```ts
import { Inbox } from '@kuiper-sh/inbox';

const inbox = new Inbox({ apiKey: 'kgw_…' });

const mailbox = await inbox.mailboxes.get('concierge');   // its ID, address or prefix
await mailbox.send({ to: 'ann@example.com', subject: 'Your order', text: 'It shipped today.' });

for (const summary of (await mailbox.messages({ unread: true })).messages) {
  const msg = await inbox.messages.get(summary.id);       // text, html, attachments
  if (!msg.automated) await inbox.messages.reply(msg.id, { text: 'Thanks, on it.' });
  await inbox.messages.markRead(msg.id);
}
```

`@kuiper-sh/sdk` has the same as `kuiper.inbox`. See [TypeScript SDK](https://kuiper.sh/docs/sdk.md).

Messages marked `automated` were sent by a program: auto-replies, mailing lists, bounces. Don't answer them automatically, or two agents can answer each other forever.

## Threads

A reply joins the thread of the message it answers, in both mailboxes when both are on Kuiper. `inbox.messages.reply(id, …)` answers the sender (or `Reply-To`), adds `Re:` and threads it. `reply_all: true` copies everyone else on the message.

```ts
const thread = await inbox.threads.get(msg.thread_id);  // every message, oldest first
const page = await mailbox.threads({ unread: true });   // the latest activity first
```

## HTML

Send `html`, with or without `text`. Without it, Kuiper makes the plain-text part from the HTML. To show an image inside the HTML, attach it with a `content_id` and use `cid:` in the HTML.

```ts
const draft = {
  to: 'ann@example.com',
  subject: 'Your invoice',
  html: '<p>Hi Ann,</p><p>Your invoice is attached.</p><img src="cid:logo">',
  attachments: [
    { filename: 'logo.png', content: logoBytes, content_id: 'logo' },
    { filename: 'invoice.pdf', content: pdfBytes },
  ],
};

const preview = await mailbox.preview(draft);   // nothing is sent
console.log(preview.text);       // the plain text recipients get
console.log(preview.size);       // bytes, signed
console.log(preview.problems);   // what would stop it, if anything
await mailbox.send(draft);
```

A preview is built and signed exactly as sending would build it. It lists where each recipient is: `kuiper` (a mailbox here), `missing` (a Kuiper address no mailbox has) or `external`. `inbox.messages.previewReply(id, …)` previews a reply.

In the dashboard, the composer switches between text and HTML and shows the HTML as you type. **Preview** shows what recipients will get.

The dashboard shows HTML mail in a sandbox that runs no scripts. Images from the internet stay hidden until you choose **Show images**, or **Always from** the sender's domain, so a sender can't tell you opened the mail. Links open in a new tab. Attached images, PDFs, audio, video and text open in the page.

## Attachments

```ts
await mailbox.send({
  to: 'ann@example.com',
  subject: 'Photos',
  text: 'Two photos.',
  attachments: [{ filename: 'a.jpg', content: bytes }],   // Uint8Array, ArrayBuffer or base64
});

const file = await inbox.messages.attachment(msg.id, 0);  // { filename, contentType, data }
const source = await inbox.messages.raw(msg.id);          // the whole message, as an .eml file holds it
```

A message, attachments included, is up to 25 MiB.

## Webhooks

A mailbox's webhook hears about mail as it happens:

| Event | When |
| --- | --- |
| `message.received` | Mail arrived. The event carries the message, its bodies included |
| `message.sent` | Every recipient of a message you sent has it |
| `message.failed` | Some or all recipients won't get it. `deliveries` says who and why |
| `ping` | **Send a test event**, or `mailbox.testWebhook()` |

### Point it at a unit, or a URL

A webhook can be a unit in the same project. Kuiper calls it through the machine it runs on, so it needs no public address. Kuiper follows it when it redeploys, moves or is renamed.

In the dashboard, open the mailbox's settings and choose **A unit in this project**, the unit, the port and the path. Or:

```ts
await mailbox.update({ webhook: { unit: 'concierge-agent', path: '/hooks/inbox' } });  // its main port
await mailbox.update({ webhook: { unit: 'concierge-agent', port: 'http', path: '/hooks/inbox' } });
await mailbox.update({ webhook: 'https://agent.example.com/hooks/inbox' });           // anywhere else
await mailbox.update({ webhook: null });                                              // none
```

```sh
kuiper inbox update concierge --webhook unit://concierge-agent/hooks/inbox
kuiper inbox update concierge --webhook unit://concierge-agent:8080/hooks/inbox
kuiper inbox update concierge --webhook https://agent.example.com/hooks/inbox
```

- **The port** is one your unit's `kuiper.json` declares, by number or name. Without one, it is the unit's main port: its first public port, else its first.
- **The path** may carry a query: `/hooks/inbox?source=kuiper`.
- **A URL** must be a public `https://` address.

### Check the signature

Every event is a JSON POST signed with the mailbox's secret:

```text
Kuiper-Signature: t=1760000000,v1=<hex HMAC-SHA256 of "<t>.<body>">
Kuiper-Event: message.received
Kuiper-Event-Id: evt_…
```

`webhookHandler` checks it and answers for you:

```ts
import { webhookHandler } from '@kuiper-sh/inbox';

export const POST = webhookHandler({
  secret: process.env.INBOX_WEBHOOK_SECRET!,
  onEvent: async (event) => {
    if (event.type === 'message.received') console.log(event.data.message.subject);
  },
});
```

`verifyWebhook(body, signature, secret)` checks one by hand. Pass the body exactly as it arrived.

- A `2xx` answer delivers the event. Anything else, or no answer in 15 seconds, is sent again later, for about a day. Deliveries of one event share its `id`, so handle each `id` once.
- Events older than five minutes are refused, so a captured request can't be replayed.
- `mailbox.rotateSecret()` (or **New webhook secret**) replaces the secret. The old one stops working at once.
- The mailbox's settings list recent events and what each answer was.

## Glove and Foundry

`@kuiper-sh/inbox/glove` gives a Glove agent tools: `check_email`, `read_email`, `get_email_attachment`, `send_email` and `reply_email`.

```ts
import { Inbox } from '@kuiper-sh/inbox';
import { inboxTools } from '@kuiper-sh/inbox/glove';

const inbox = new Inbox({ apiKey, gatewayUrl });  // from ${{inbox.API_KEY}} and ${{inbox.BASE_URL}}
for (const tool of inboxTools(inbox, { mailbox: 'concierge', allowRecipient: (a) => a.endsWith('@yourcompany.com') })) {
  glove.fold(tool);
}
```

- `allowRecipient` limits who the agent may write to, and `readOnly` leaves out sending.
- `send_email` and `reply_email` take `wait_for_reply`. The agent waits in Glove's inbox, and `resolveEmailReplies(store, event)` in your webhook handler hands it the answer.
- Email is written by anyone. The tools mark it as information, not instructions. Still, don't give an agent with a mailbox tools it shouldn't use on a stranger's say-so.

## Your own domain

In the **Inbox** page, under **Your domains**, add a domain. Or:

```sh
kuiper inbox domains add mail.yourcompany.com
```

Publish the records it lists at your DNS provider:

| Record | Name | Why |
| --- | --- | --- |
| TXT | `_kuiper-inbox.<domain>` | Proves the domain is yours |
| MX | `<domain>` | Brings its mail to Kuiper |
| TXT | `kuiper1._domainkey.<domain>` | The key Kuiper signs its mail with (DKIM) |
| TXT | `<domain>` | Lets Kuiper send for it (SPF) |
| TXT | `_dmarc.<domain>` | DMARC |

Kuiper checks them every few minutes. **Check now** (or `kuiper inbox domains check`) checks at once. Once the first record is found, the domain takes mailboxes:

```sh
kuiper inbox create support --domain mail.yourcompany.com
```

Use a subdomain such as `mail.yourcompany.com` if `yourcompany.com` already has mail elsewhere: its MX record would move all of it.

## Limits

| | |
| --- | --- |
| Message size | 25 MiB, attachments included |
| Mailboxes | 100 per project |
| Sending outside Kuiper | 500 recipients a day per organisation |
| Receiving | 600 messages an hour per mailbox; past it, senders try again later |

Mail between Kuiper's mailboxes is always on. Whether mail comes from and goes to the rest of the internet depends on the Kuiper you use: the **Inbox** page says so when either is off, and sends that can't go out are refused with a message that says why.

## Every endpoint

All under `${{inbox.BASE_URL}}/v1`. A mailbox is its ID, its address or its prefix.

| Method and path | What |
| --- | --- |
| `GET /` | The project's inbox: whether mail comes and goes beyond Kuiper, its mailboxes and domains |
| `GET /mailboxes`, `POST /mailboxes` | List mailboxes; make one: `name`, `domain`, `display_name`, `webhook` |
| `GET`, `PATCH`, `DELETE /mailboxes/{mailbox}` | One mailbox; change `display_name` or `webhook`; delete it and its mail |
| `POST /mailboxes/{mailbox}/secret` | A new webhook secret |
| `POST /mailboxes/{mailbox}/webhook/test` | Send a `ping` |
| `GET /mailboxes/{mailbox}/events` | Recent webhook events |
| `GET /mailboxes/{mailbox}/messages` | Messages, newest first: `direction`, `unread`, `thread`, `q`, `limit`, `cursor` |
| `POST /mailboxes/{mailbox}/messages` | Send: `to`, `cc`, `bcc`, `reply_to`, `subject`, `text`, `html`, `attachments`, `in_reply_to`, `headers` (`X-…`), `from_name` |
| `POST /mailboxes/{mailbox}/messages/preview` | What that send would be, unsent |
| `GET /mailboxes/{mailbox}/threads` | Threads, the latest activity first |
| `GET`, `DELETE /threads/{thread}`; `POST /threads/{thread}/read` | A thread with its messages; delete it; mark it read |
| `GET`, `PATCH`, `DELETE /messages/{message}` | A message; `{"read": true}`; delete it |
| `POST /messages/{message}/reply`, `…/reply/preview` | Answer it: `text`, `html`, `reply_all`, `cc`, `bcc`, `attachments`, `headers`, `from_name`; or preview the answer |
| `GET /messages/{message}/raw` | Its source |
| `GET /messages/{message}/attachments/{index}` | One attachment |
| `GET /domains`, `GET /domains/{domain}` | The project's domains and their records |

Attachments go as `{"filename", "content_type", "content": "<base64>", "content_id"}`. Recipients are `"ann@example.com"`, `"Ann <ann@example.com>, bo@example.com"`, or a list of either or of `{"name", "address"}`.
