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
Install and authenticate
npm install @mcpmailer/sdk
export MCPMAILER_API_KEY=mmk_live_...import { Mcpmailer } from '@mcpmailer/sdk';
const mail = new Mcpmailer(); // reads MCPMAILER_API_KEYThe 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
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.
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.
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.
// 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 informationDeciding how long to wait before nudging is a product decision either way, worked through in long-running email conversations.
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.
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
import { connect } from '@mcpmailer/sdk';
await connect({ handle: 'scout', target: 'http://localhost:3000' });
// https://scout.mcpmailerwire.com now reaches your local serverPoint 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
- A system prompt with explicit escalation triggers.
- Whitelist mode on the identity until you trust it.
- Verified authentication on the sending domain.
- A golden thread suite replayed against a test identity.
- 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, setMCPMAILER_API_KEY, and callmessages.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_suppressedmeans stop, and a spend cap means only the workspace owner can raise it.- How does the agent wait for a reply?
waitForReplyblocks 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