An email agent in TypeScript, from empty folder to first reply

Most of what makes an email agent good is decided in about forty lines of TypeScript: whether it reads the thread before answering, whether it looks the sender up, and what it does when a send is refused. This is those forty lines, with the reasoning attached.

3 min read

A TypeScript agent sending and receiving from its own address
One key, one identity, and the same rules the MCP tools follow.

Install and authenticate

Shell
npm install @mcpmailer/sdk
export MCPMAILER_API_KEY=mmk_live_...
TypeScript
import { Mcpmailer } from '@mcpmailer/sdk';

const mail = new Mcpmailer(); // reads MCPMAILER_API_KEY

The key is scoped to one agent identity: one handle, one mailbox, one credential. Everything below acts as that agent and cannot reach another agent's mail. Get one from the quickstart, or have the agent provision its own if it is created at runtime.

If you would rather have your editor do this part, npx skills add mcpmailer/skill gives a coding agent the instructions to write the config, check the key, and send a test message.

Send, and read what comes back

TypeScript
const res = await mail.send({
  to: ['anna@customer.com'],
  subject: 'Your order 4012',
  body: 'Hi Anna,\n\nYour refund was issued today and reaches your card in 3 business days.'
});

Bodies are markdown, delivered as text plus HTML. Five recipients per message is a hard cap.

The return is { status, messageId?, reason?, retryAfter? }, and the second half of that shape is the part worth writing code for. A rejection is not an exception, it is an answer.

TypeScript
if (res.status === 'rejected') {
  switch (res.reason) {
    case 'daily_send_quota_exhausted':
    case 'monthly_send_quota_exhausted':
      return schedule(res.retryAfter ?? 3600, payload); // wait for the reset
    case 'recipient_suppressed':
      return escalate(res);                             // bounced or complained before
    case 'monthly_spend_cap_reached':
      return notifyOwner(res);                          // only they can raise it
    case 'sending_locked_verify_email':
      return notifyOwner(res);                          // workspace email not verified
    default:
      return escalate(res);
  }
}

Retrying a refusal immediately is how a limit that protects your sending domain becomes a reputation problem. The reasons are stable strings for exactly this.

Answer the conversation, not the message

Two calls before you write anything. They are the difference between an agent that sounds competent and one that asks a customer to repeat themselves.

TypeScript
const thread = await mail.getThread(threadId);            // quoted history stripped
const [contact] = await mail.lookupContact('anna@customer.com');

const body = await yourModel({ thread, contact });        // your LLM call
await mail.replyAll(messageId, body);
await mail.rememberAboutContact(contact.id, 'Prefers delivery to the office.');

That last line is the one people skip, and nothing breaks for a fortnight. Then your agent is still asking a two-year customer which product they use. Details in giving an email agent memory.

Waiting for the reply

There is no waitForReply in the TypeScript client, and that is deliberate rather than an omission. A process holding an open wait for a day is a process you have to keep alive for a day, and on a serverless runtime it is a bill. Take the webhook in the next section: the wait costs nothing while nothing is happening, and the agent does not have to be running when the answer arrives.

TypeScript
// Over MCP the server does hold the wait for you, up to five minutes at a time.
// From this client, subscribe and let the reply come to you.
if (!reply) return followUpOnce(threadId);   // silence is information

Deciding how long to wait before nudging is a product decision either way, worked through in long-running email conversations.

The loop: thread, contact, decide, reply, remember
Five calls. The interesting decisions are in the middle one.

Receive mail without polling

Register a webhook for message.received and handle it properly: verify the signature over the raw body before parsing, acknowledge fast, and do the work outside the request.

TypeScript
export async function POST(request: Request) {
  const raw = await request.text();
  if (!verify(raw, request.headers.get('x-mcpmailer-signature'))) {
    return new Response('bad signature', { status: 401 });
  }

  const event = JSON.parse(raw);
  if (await seen(event.message_id)) return new Response('ok'); // retries are certain
  await queue.push(event);
  await markSeen(event.message_id);
  return new Response('ok');
}

Three properties, all non-optional: signature verification, idempotency keyed on the message id, and a fast acknowledgement with the model call outside the handler. Skip any one and you will eventually answer twice or hand someone a way to start runs on your account. More in webhooks or polling.

Run it locally

TypeScript
import { connect } from '@mcpmailer/sdk';

await connect({ handle: 'scout', target: 'http://localhost:3000' });
// https://scout.mcpmailerwire.com now reaches your local server

Point the webhook at that hostname and it survives restarts and network changes, which a fresh tunnel URL every run does not. Only the key belonging to that handle can bring its tunnel up. See tunnels.

What to add before it meets a customer

  1. A system prompt with explicit escalation triggers.
  2. Whitelist mode on the identity until you trust it.
  3. Verified authentication on the sending domain.
  4. A golden thread suite replayed against a test identity.
  5. Every thread read by a person in week one.

The same agent over MCP, so a model calls the tools directly rather than your code calling them, is in what an MCP email server is. Mixing both is normal: MCP where the model decides, the SDK where your code already knows.

Questions

How do I send email from a TypeScript agent?
npm install @mcpmailer/sdk, set MCPMAILER_API_KEY, and call messages.send. The key is scoped to one agent identity, and bodies are markdown delivered as text plus HTML.
Do I need MCP for this?
No. MCP is for letting a model call the tools itself. Code that already knows what to do uses the SDK or REST with the same key and the same rules.
What does the SDK return on a refused send?
{ status: 'rejected', reason, retryAfter? }. Quota reasons carry a reset, recipient_suppressed means stop, and a spend cap means only the workspace owner can raise it.
How does the agent wait for a reply?
waitForReply blocks server-side and returns when the reply lands or the timeout expires, so there is no scheduler and no polling.
How do I test webhook handling locally?
Open a tunnel with connect({ handle, target }) and point the webhook at the identity's stable hostname, which survives restarts.
What is the most common mistake in this code?
A webhook handler that is not idempotent. Deliveries retry by design, so without a handled-message check the agent will eventually reply twice.

Give your agent an address it can answer from.

Create an inbox