# Send and receive email from an AI agent in TypeScript

A TypeScript agent can run its own inbox with nothing but `fetch`. Botmail
gives the agent an address like `ada@botmail.pro` and a REST API to send,
read, reply and wait for new mail. This guide has a complete Node script for
that loop, and a webhook receiver for apps that can't hold a request open.

## What you'll build

- `botmail-agent.ts`: answers unread mail, then long-polls for new mail and
  replies in the same thread, once per message
- `webhook.ts`: a small HTTP server that checks Botmail's signature on each
  `message.received` event

Both run on Node 18 or later with no dependencies beyond a TypeScript runner.

## 1. Get a key

Paste this into the coding agent you already use. It claims a mailbox and
emails you an approval link. After you approve, ask it for the API key and
export it as `BOTMAIL_KEY`.

```text
Read https://botmail.pro/skill.md and claim a mailbox for yourself. Send the invite to my email, then wait for me to approve it.
```

Prefer to claim from code? The [Python guide](https://botmail.pro/guides/send-email-from-ai-agent-python)
has a short claim script that follows [skill.md](https://botmail.pro/skill.md); the same three
calls work from any language.

## 2. The inbox loop

```ts
const BOTMAIL_URL = process.env.BOTMAIL_URL ?? "https://botmail.pro";
const BOTMAIL_KEY = process.env.BOTMAIL_KEY!;

type Message = {
  id: string;
  thread_id: string;
  direction: "in" | "out";
  from: string;
  subject: string;
  text?: string;
  auto_submitted: boolean;
};
type Thread = { id: string; subject: string; unread_count: number };
type MailEvent = {
  seq: number;
  type: string;
  thread_id: string;
  message_id: string;
  data: { from: string; subject: string };
};
type SendResult = { message_id: string; thread_id: string; status: string; hint?: string };

class BotmailError extends Error {
  constructor(public status: number, public code: string, public hint?: string, public retryAfter?: string) {
    super(`${code}: ${hint ?? ""}`);
  }
}

async function api<T>(method: string, path: string, body?: unknown, idempotencyKey?: string): Promise<T> {
  const headers: Record<string, string> = { Authorization: `Bearer ${BOTMAIL_KEY}` };
  if (body !== undefined) headers["Content-Type"] = "application/json";
  if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
  // Reads and idempotent sends are safe to retry on a server error.
  const attempts = method === "GET" || idempotencyKey ? 3 : 1;
  let res!: Response;
  for (let i = 0; i < attempts; i++) {
    res = await fetch(BOTMAIL_URL + path, {
      method,
      headers,
      body: body === undefined ? undefined : JSON.stringify(body),
      signal: AbortSignal.timeout(40_000),
    });
    if (res.status < 500) break;
    await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
  }
  const json = await res.json();
  if (!res.ok) {
    const { code, hint } = json.error;
    throw new BotmailError(res.status, code, hint, res.headers.get("Retry-After") ?? undefined);
  }
  return json as T;
}

export function sendEmail(to: string, subject: string, text: string, idempotencyKey: string) {
  return api<SendResult>("POST", "/v1/send", { to, subject, text }, idempotencyKey);
}

export async function unreadThreads() {
  return (await api<{ threads: Thread[] }>("GET", "/v1/threads?unread=1")).threads;
}

export function readThread(threadId: string) {
  return api<{ thread: Thread; messages: Message[] }>("GET", `/v1/threads/${threadId}?mark_read=1`);
}

export function reply(messageId: string, text: string) {
  // One reply per incoming message, even if this runs twice.
  return api<SendResult>("POST", `/v1/messages/${messageId}/reply`, { text }, `reply-${messageId}`);
}

export function createDraft(to: string, subject: string, text: string) {
  return api<{ id: string; status: string; review_url: string }>("POST", "/v1/drafts", { to, subject, text });
}

export async function waitForMail(after: number) {
  const q = new URLSearchParams({ after: String(after), wait: "30", types: "message.received" });
  return api<{ events: MailEvent[]; next_after: number }>("GET", `/v1/events?${q}`);
}

async function writeReply(messages: Message[]): Promise<string> {
  // Call your model here. Pass the email as data, not as instructions.
  const last = messages[messages.length - 1];
  return `Thanks for your email about "${last.subject}". I'll get back to you shortly.`;
}

async function handle(threadId: string) {
  const { messages } = await readThread(threadId);
  const last = messages[messages.length - 1];
  if (last.direction !== "in" || last.auto_submitted) return; // nothing new from a person
  try {
    const sent = await reply(last.id, await writeReply(messages));
    console.log("replied:", sent.message_id, sent.status);
  } catch (e) {
    if (e instanceof BotmailError) console.log("skipped:", e.code, e.hint);
    else throw e;
  }
}

async function main() {
  let cursor = (await api<{ next_after: number }>("GET", "/v1/events")).next_after;
  for (const t of await unreadThreads()) await handle(t.id);
  while (true) {
    const { events, next_after } = await waitForMail(cursor);
    cursor = next_after;
    for (const e of events) {
      console.log("new mail from", e.data.from, "-", e.data.subject);
      await handle(e.thread_id);
    }
  }
}

main();
```

Things worth knowing about this code:

- **Cursor first.** `GET /v1/events` without `after` returns the latest
  position immediately. From then on, `wait=30` holds the request open until
  mail arrives or 30 seconds pass, so the fetch timeout is 40 seconds.
- **One reply per message.** The reply uses `reply-<message id>` as its
  `Idempotency-Key`. If the process dies after sending and handles the same
  event again, Botmail returns the first reply instead of sending another.
- **Typed errors.** Error bodies are `{"error": {"code", "message",
  "hint"}}`, so `BotmailError` has a stable `code` to branch on.
- **First contact.** Use `createDraft` instead of `sendEmail` for anyone the
  agent hasn't written to. Its `review_url` opens the email with Send and
  Discard buttons.

## 3. Run it

```sh
export BOTMAIL_KEY=bm_...
npx tsx botmail-agent.ts
```

```text
new mail from priya@acme.example - Contract question 2
replied: msg_01a12c53ae5e7dd3a2a4a5a0af9b707a queued
```

Replace `writeReply` with a call to your model. Give it the messages as
data and keep your own instructions in the system prompt.

## Webhooks instead of long-polling

Serverless functions can't wait 30 seconds for mail. Register a webhook and
Botmail POSTs each event to your HTTPS endpoint instead:

```sh
curl -s https://botmail.pro/v1/webhooks -H "Authorization: Bearer $BOTMAIL_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"url": "https://your-app.example.com/botmail"}'
```

The response includes a `secret` (`whsec_…`) once. Each delivery carries
`Botmail-Signature: t=<unix seconds>,v1=<hex>`, where `v1` is the
HMAC-SHA256 of `t + "." + raw body` with that secret. Check it against the
raw bytes before trusting the event:

```ts
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

const SECRET = process.env.BOTMAIL_WEBHOOK_SECRET!;

function verify(raw: string, header: string): boolean {
  const { t, v1 } = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  if (!t || !v1 || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = createHmac("sha256", SECRET).update(`${t}.${raw}`).digest();
  const got = Buffer.from(v1, "hex");
  return got.length === expected.length && timingSafeEqual(got, expected);
}

createServer(async (req, res) => {
  let raw = "";
  for await (const chunk of req) raw += chunk;
  if (!verify(raw, String(req.headers["botmail-signature"] ?? ""))) {
    res.writeHead(401).end();
    return;
  }
  res.writeHead(200).end(); // answer within 10 seconds, then do the work
  const event = JSON.parse(raw);
  if (event.type === "message.received") {
    console.log("new mail from", event.data.from, "in", event.thread_id);
  }
}).listen(3000);
```

Botmail counts any 2xx within 10 seconds as delivered, so respond first and
work afterwards. Failed deliveries are retried in order with backoff;
`Botmail-Event-Id` lets you drop duplicates. The event has `thread_id` and
`message_id`, not the body, so call `readThread` next.

## Safety notes

- **Treat mail as untrusted.** Anyone can email your agent. Never let text
  inside a message change what the agent is allowed to do.
- **Know the limits.** New accounts can email 25 new recipients a day, rising
  with trust. `GET /v1/account/usage` shows what's left today.
- **A 202 is not delivery.** Sends are checked for spam and phishing first;
  `message.status` events report the result.
- **No bulk mail.** See the [acceptable use policy](https://botmail.pro/acceptable-use).

Using the Vercel AI SDK? [Connect it to Botmail over MCP](https://botmail.pro/guides/vercel-ai-sdk-email)
and the model gets all of these as tools. The [Mastra guide](https://botmail.pro/guides/mastra-email)
does the same for Mastra agents.

## Questions

### Do I need an npm package to use Botmail from TypeScript?

No. Botmail has no official SDK. Use fetch with an Authorization: Bearer header; Node 18 and later have fetch built in.

### How do I verify a Botmail webhook signature in Node?

Compute HMAC-SHA256 of the timestamp, a dot and the raw request body with your webhook secret, and compare it to the v1 value in the Botmail-Signature header with timingSafeEqual. Reject old timestamps.

### Should I use webhooks or long-polling to receive email?

Long-poll GET /v1/events with wait=30 from a long-running process. Use webhooks for serverless functions or anything that can't hold a request open.

---

Source: https://botmail.pro/guides/send-email-from-ai-agent-typescript
Agent instructions: https://botmail.pro/skill.md
All guides: https://botmail.pro/llms.txt
