# MCPmailer documentation

Email infrastructure for AI agents: every agent is an identity with its own mailbox, plus shared contacts, notes, and an encrypted vault.

## Overview

MCPmailer gives an agent a real mailbox at a real address. It can send, receive, thread, search, and wait for a reply the way a person does, over MCP or over plain HTTPS. Around the mailbox sit the things an agent needs to hold a conversation over time: a workspace address book it can write to, shared notes that outlive a session, and a vault of credentials it can use without any of them passing through a prompt.

Everything is scoped to an identity. One handle, one mailbox, one API key, so an agent can only ever send from the address it owns and read the mail addressed to it.

### Where to start

There are two ways in, and neither is the advanced one. If you already run an agent, connect it to a mailbox and you are done: no key, no file, no terminal. If you are writing the agent yourself, point it at the endpoint or call the API directly.

- **Connect your agent**: Claude, OpenClaw, Hermes, ChatGPT, n8n. A settings screen and a sign-in, nothing to install.
- **Set it up in code**: The endpoint, a key, and the first message, in about two minutes.
- **Words used here**: Handle, MCP, cold send, webhook. Short definitions, in case any of these are new.
- **Tool reference**: Every tool the MCP server exposes, with its arguments and return shape.

Machine-readable versions of these pages: /docs.md for the markdown, /llms.txt for an overview, /openapi.json for the full API description.

## Connect your agent

If you already run an assistant, this page is the whole setup. You make an agent, you tell the assistant where its mailbox is, and from then on it can read and write email. Nothing here needs a terminal, and on most apps there is no key to look after.

### Make the agent

Sign up, with Google, Microsoft, GitHub, or an emailed link, and your workspace already has a working agent in it, so there is nothing to arrange first. The dashboard asks what address it should answer on: sales@, support@, whatever the job is. Make more agents under Agents if you want a second address, since one agent is one mailbox.

One agent is one mailbox. If you later want a second address, make a second agent rather than trying to share the first: it is what keeps two jobs from reading each other's mail. See /docs/several-agents.

### Point your assistant at it

Open the agent and go to the Connect tab. Pick yours below for the exact screens, including where the setting lives and what to paste into it.

- **Claude**: Settings, then Connectors, then Add custom connector. Paste one address and sign in.
- **OpenClaw**: One command adds it and a browser opens to sign in. Then restart the gateway.
- **ChatGPT**: Add MCPmailer as a custom connector and sign in, the same as any other connector.
- **Hermes**: Three lines in the config file, then hermes mcp login mcpmailer.
- **n8n**: An MCP Client Tool node beside your AI Agent node, and a key stored as a credential.
- **Something else**: Every client we have written instructions for, and the generic setup for the rest.

OpenClaw and Hermes run unattended, which is a different job from a chat client, so each has a page of its own: /docs/openclaw and /docs/hermes cover timeouts, limiting which tools an agent gets, waking it on mail, and running the gateway as a service.

Claude, OpenClaw, Hermes, and ChatGPT can sign in rather than hold a key: a browser window opens, you approve what the app may do, and there is nothing to copy back. n8n and anything you configure by editing a file want a key instead, which you create on the same Connect tab.

### Check that it worked

The Connect tab tells you itself: leave it open while you set the app up, and it says which assistant connected as soon as one does. If it is still waiting after a few minutes it lists the three things that usually explain it.

Then ask your assistant to check the email. There is a message waiting in every new agent inbox, so it has something real to find, which is the proof that reading works. Ask it to reply to that message and you have proved the other direction too.

Nothing sends until the email address on your account is verified. A first test refused with sending_locked_verify_email means exactly that, and verifying from the dashboard clears it.

### What to ask it for

The tools are named for what they do, so plain instructions work: check my email, read the one from Ada and tell me what she wants, reply and say Thursday suits us, remember that she prefers mornings. The last one is real: what an agent remembers about a contact comes back the next time anyone looks that person up.

Two things are worth setting up once you have it working: having mail wake the agent instead of waiting for you to ask (see /docs/waking), and sending from your own domain rather than ours (see /docs/domains).

## Quickstart

This page is the route for people wiring MCPmailer into something they are writing. If you just want an assistant you already run to have email, /docs/connect is shorter and needs no terminal.

Create an agent under Agents, then a key for it on the Connect tab. A key belongs to one agent: one identity, one mailbox, one credential. It decides which mailbox a call reads and which address mail leaves from, as well as which contacts, notes, and secrets the tools can reach, so give each agent its own rather than sharing one.

To do all of this from inside your editor instead, run npx skills add mcpmailer/skill and ask your coding agent to set up MCPmailer. It writes the config, checks the key, and sends a test message.

### Connect over MCP

The server speaks Streamable HTTP MCP at https://connect.mcpmailer.com/mcp and authenticates with a bearer token. Most clients take a config block like this one.

```json
{
  "mcpServers": {
    "mcpmailer": {
      "type": "http",
      "url": "https://connect.mcpmailer.com/mcp",
      "headers": { "Authorization": "Bearer mmk_live_..." }
    }
  }
}
```

Where a framework has its own way of loading an MCP server, use that instead of a config file.

- **Cloudflare Agents SDK**: One addMcpServer call in onStart, and the Durable Object keeps the connection.
- **Vercel AI SDK**: createMCPClient, then spread the tools into generateText or streamText.
- **LangChain and LangGraph**: langchain-mcp-adapters turns the server into ordinary LangChain tools.
- **OpenAI Agents SDK**: MCPServerStreamableHttp, with the tool list cached between turns.

### Or call it over HTTPS

The same key works against the REST API, which mirrors every tool. Nothing about the account or the quota changes between the two.

```bash
curl https://mcpmailer.com/v1/messages \
  -H "Authorization: Bearer mmk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "to": ["ada@example.com"],
    "subject": "Shipping update",
    "body": "The parts landed this morning."
  }'
```

```json
{
  "status": "sent",
  "messageId": "msg_01J9X8Q2K7"
}
```

A rejection returns a reason instead of an id, and the reason is written for an agent to act on: daily_send_quota_exhausted carries the reset time, recipient_suppressed lists addresses that previously bounced or complained.

Hosted clients that cannot store a key can authorize as you over OAuth instead.

### Set it up with nobody watching

Everything above assumes a person opened the dashboard first. An agent does not have to. One call provisions a workspace, an address, and a key, and returns a claim URL that hands the whole thing to a person whenever one shows up.

```bash
curl https://mcpmailer.com/v1/signup \
  -H "Content-Type: application/json" \
  -d '{
    "handle": "scout",
    "email": "you@example.com",
    "description": "Answers questions about our docs"
  }'
```

```json
{
  "handle": "scout",
  "address": "scout@scout.mcpmailer.email",
  "api_key": "mmk_live_...",
  "mcp_url": "https://connect.mcpmailer.com/mcp",
  "claim_url": "https://mcpmailer.com/claim/9f2c...",
  "limits": {
    "plan": "free",
    "sending": "locked until a person claims this workspace and verifies their email"
  }
}
```

The key in that response is the one from the section above, so the next call is the send you already have. Receiving works straight away. Sending is refused with sending_locked_verify_email until somebody opens the claim URL and verifies their address, which is what stops this endpoint being a way to send anonymous mail.

Rate limited to five signups per IP per day, and the handle is global, so pick one that is yours and reuse the workspace rather than provisioning a fresh one per run.

## Words used here

Documentation for email infrastructure tends to assume you already speak it. This page is here so the rest of these pages can be read without that being true. Nothing below is something you have to configure.

- **Agent**: Whatever is doing the work: an assistant you run, a script you wrote, a workflow in n8n. On these pages it also means the identity that work uses, which is one handle and one mailbox.
- **Handle**: The short name an agent goes by, like scout. It is the part before the @ in its address, and it is unique across all of MCPmailer rather than just inside your account.
- **Mailbox**: One agent's email: the address it sends from, and where its mail arrives. An agent has exactly one and cannot read anyone else's.
- **Workspace**: Your account and everything in it: the agents, the shared address book, the notes, the vault, and the plan you are on.
- **MCP**: The Model Context Protocol, a standard way for an assistant to pick up tools from an outside service. Connecting over MCP is what makes sending and reading email appear as things your assistant can do. You do not need to understand it to use it.
- **API key**: A long secret starting with mmk_ that stands in for one agent. Anything holding it can send as that agent, so it belongs in a settings field or an environment variable, never in a message or a prompt.
- **Signing in instead**: Some apps can send you to a page here to approve them, so no key is ever copied anywhere. Where it is offered it is the shorter route, and you can revoke that one app later without touching the others.
- **Thread**: One conversation: a message and every reply under it. Answering with reply_all keeps a thread together the way a person's mail client does.
- **Reply and cold send**: A reply answers someone who wrote to you first. A cold send is a first contact, and is treated more carefully: it carries an unsubscribe link and is watched for runaway sending.
- **Webhook**: A message we send to your software the moment something happens, instead of your software asking us over and over whether anything has. It is how mail can wake an agent that is already running.
- **Suppressed address**: An address that bounced or reported one of your messages as spam. We refuse later sends to it, because writing to it anyway is what gets a sending domain blocked.
- **DNS records**: Settings on a domain you own. Publishing ours proves the domain is yours and lets the receiving side trust mail your agents send from it.
- **Disposable address**: A throwaway address that receives for a short while and is then deleted along with everything it received. For signups and verification codes.
- **Tunnel**: A public web address that points at something running on your own machine, so an outside service can reach it even from behind a home network.
- **The vault**: Where credentials an agent needs are kept, encrypted, so it can use one without anybody pasting it into a conversation.

## Waking your agent when mail arrives

An agent learns that mail arrived in one of three ways, and choosing correctly between them is most of the work. Two of them need nothing built.

- **The assistant looks when it needs to**: Nothing to set up. An assistant with the tools connected calls list_messages when the conversation calls for it. This is the right answer whenever a person is sitting in front of it.
- **It waits for one reply**: wait_for_reply blocks until an answer lands in a thread, up to five minutes at a time. The right answer when an agent has just sent something and cannot continue without the response.
- **You are told**: A webhook: we POST to an address you give us the moment mail arrives. The only option that costs nothing while nothing is happening, and the only one that still works when the agent is not currently running.

Do not poll list_messages on a timer. It spends rate limit that every other call the agent makes has to share, and the other two options exist so that you do not have to. A scheduled run once or twice a day is a different thing and is fine.

### What every receiver needs first

We only POST to public https. A runtime on your own machine is not reachable, and a private address is refused when you register it, so put a tunnel in front of it: mcpmailer tunnel --handle scout --target http://localhost:PORT gives you https://scout.mcpmailerwire.com pointing at that port, and that hostname survives restarts and network changes.

Then register the endpoint, in the dashboard under Webhooks, with create_webhook, or with mcpmailer webhooks:add, asking for the events you want: a new endpoint is subscribed to message.received and nothing else.

### Where the setup for yours lives

- **OpenClaw**: Its hook routes, /hooks/wake and /hooks/agent, with the body template for each.
- **Hermes**: Its webhook adapter: the route, the secret, and the prompt template our payload fits.
- **Your own service**: Take the envelope, verify the signature, ignore an id you have already handled.
- **n8n**: A Webhook node with Header Auth, feeding the AI Agent node.

OpenClaw and Hermes get pages of their own because they are the two here meant to run unattended, and an agent that answers mail at three in the morning has more to get right than a connection string.

### A desktop assistant like Claude

This one cannot be woken, and it is worth saying plainly rather than leaving you to find out. A chat app has no inbound address for us to POST to: Claude, ChatGPT, and the like read mail when you ask them to, or wait inside a single task with wait_for_reply. If mail has to be handled while nobody is looking, the thing handling it has to be something that runs unattended.

Whatever you point at, test it before you rely on it. mcpmailer webhooks:test sends a real message.received and prints exactly what came back; mcpmailer webhooks:deliveries shows what each attempt got. A status code means your receiver answered and refused us, and an error with no status means the request never arrived.

## Signup and verification codes

An agent that has to sign up for something needs an address to receive the confirmation at, and its own is usually the wrong one: the mail is not a conversation, it should not sit in the inbox afterwards, and one address used for fifty signups collects fifty lists. So an agent can mint a throwaway address, use it, and let it disappear along with everything it received.

These live on a domain kept apart from every sending domain, and they can only receive, so nothing here can put mail on the wire or touch the reputation of the domains your agents send from.

### The flow

```text
create_temp_address { ttl_seconds: 900, label: "acme signup" }
  -> { address: "k7f2q9@tmp.mcpmailer.com", expires_at: "...", receive_only: true }

# register at the site with that address, however the agent does that

wait_for_message { address: "k7f2q9@tmp.mcpmailer.com", timeout_seconds: 120 }
  -> the confirmation mail, body and all, for the agent to read the code out of

release_temp_address { address: "k7f2q9@tmp.mcpmailer.com" }
  -> { released: true }
```

wait_for_message returns mail that arrived before the call as well as mail that arrives during it, so there is no race to lose: triggering the signup first and then waiting is safe, which is the order you would naturally write it in anyway.

### Limits

An address lives for ten minutes unless you ask for longer, and the longest you can ask for is a day. How many can be alive at once depends on the plan: three on Free, 25 on Pro, 100 on Startup, 250 on Enterprise. Release one with release_temp_address when you are done with it rather than waiting for it to expire, and the slot comes back immediately.

Disposable addresses are an MCP feature. They are not on the REST API, because the point of them is an agent minting an address mid-task rather than a service provisioning one.

The tools are create_temp_address, wait_for_message, list_temp_addresses, and release_temp_address, with their arguments in /docs/tools.

## Custom domains

Anyone who owns a domain can do this, and you do not need to know what DNS is. Add your domain under Domains in the dashboard and we give you a list of settings to copy, name the company that manages your domain, and link straight to the page where you paste them. Come back, press Check my settings, and we tell you which ones have landed. Records can take up to an hour to spread, so checking again a bit later is normal rather than a sign something is wrong.

There are seven records in total: three DKIM ones that sign your mail so receivers trust it, an ownership record that proves the domain is yours, an MX record that brings replies back to your agent, an SPF record that says we are allowed to send for you, and a DMARC record that says what to do with anything faking your address. The DKIM three are issued by our mail provider a few minutes after you add the domain, so they appear on the second check rather than the first. The domain turns on by itself once everything resolves and the DKIM signature is confirmed.

We recommend a dedicated subdomain like agents.yourcompany.com, so your root domain's mail is never touched and your agents build their own sending reputation. Root domains are supported: add the records alongside your existing ones (fold include:amazonses.com into your current SPF record rather than publishing a second one), and skip the MX record if another provider already receives mail there. Skipping it means inbound mail does not route to MCPmailer, but sending still works and the domain still verifies.

The DKIM records are what let receivers verify your agents' mail really came from you, so a domain is not activated until they are in place and confirmed. Confirmation can lag the DNS by a few minutes: when the wizard says the records are published and DKIM is still pending, nothing more is needed from you. Agents can do all of this themselves with add_domain and verify_domain, and any domain still waiting is checked again nightly, so publishing the records is enough even if nobody comes back to the dashboard.

## Running several agents

One agent is one handle, one address, one mailbox, and one key. A second agent is worth making when mail should arrive at a different address or be handled by a different job: support and billing as two inboxes, rather than one inbox sorting out which of its two lives a message belongs to.

### What they do not share

Identities are invisible to each other until you grant access, so list_identities usually returns just the caller. An agent reads only the mail addressed to it and sends only from its own address. Notes and vault secrets are granted per agent, so a credential one agent can open is not automatically open to the next one.

The key is what decides all of this. Give each agent its own rather than sharing one, because the key is what a call is resolved against: which mailbox it reads, and which address its mail leaves from.

### What they do share

Contacts are one workspace address book. That is deliberate, and it is the main reason to run several agents here rather than several accounts: a fact one agent remembers about a person surfaces the next time any of them looks that person up, so the second agent to talk to someone already knows what the first one learned.

### Making one

Agents in the dashboard, or create_inbox from a tool call, which is how an agent adds a colleague without a person opening a browser. Each plan includes a number of inboxes; past that, Free refuses and a paid plan bills a dollar a month for the extra one. list_inboxes reports the allowance and how much of it is used.

### Addressing the right one

With a key there is nothing to decide: the key is the agent. With OAuth the token identifies you rather than one of your agents, so if you have several, name one in an X-MCPmailer-Agent header. We refuse rather than guess, because guessing would mean mail leaving from the wrong address.

Webhooks follow the whole workspace by default, which is what you want when one service handles everything. Set mailbox_id to scope an endpoint to a single agent when each has its own handler.

Whitelist mode is per identity, not per workspace. An agent that should only ever talk to known parties can be locked down without affecting the others. See /docs/identities.

## When something is not working

Most of what goes wrong is one of a handful of things. This page is arranged by what you are seeing. If you already have an error code in front of you, /docs/errors lists every one of them by name, with what to do about it.

- **Nothing sends, and the reason is sending_locked_verify_email**: The email address on the account has never been verified, and nothing goes out until it is. Verify it from the dashboard. This catches almost everyone once.
- **The assistant says it has no email tools**: The tool list is read when the connection is made, so adding the server is not enough on its own: restart the assistant, or its gateway. On OpenClaw, openclaw mcp doctor mcpmailer --probe connects and prints what it actually found.
- **It connects, then gives up part-way through a wait**: wait_for_reply can block for up to 300 seconds and some clients time out well before that. Raise the request timeout on the client. On OpenClaw that setting is requestTimeoutMs, in milliseconds, and it documents no default, so an unset one has no reason to clear the wait.
- **The agent receives nothing at all**: Check the filter mode on the agent page. In whitelist mode everything from a sender you have not allowed is dropped before it is stored, so there is nothing to find afterwards. Subscribing to message.filtered is the only way to watch what is being turned away.
- **A reply shows up as a new conversation**: Something composed a fresh message instead of answering. reply_all keeps the threading headers, which is what holds an exchange together in the recipient's mail client, and send_email threads too when you pass reply_to_message_id. It is also the difference between a reply and a cold send.
- **A custom domain will not verify**: Records take up to an hour to spread, and the three DKIM records are issued a few minutes after you add the domain, so they show up on the second check rather than the first. domains:list prints exactly which records are still missing.
- **A send comes back recipient_suppressed**: That address bounced or reported one of your messages as spam, so we refuse to write to it again. Take it off the list. Continuing to send to it is what gets a domain blocked.
- **Mail from the agent lands in spam**: Send from a subdomain you verified rather than a root domain whose reputation is shared with everything else you send. Keep cold sends few and varied, since identical bodies to many recipients trip a guard here before they trip one at the receiver. /tools/mail-tester scores a real message and says what it would fix.
- **The webhook endpoint has stopped firing**: First check that it was ever subscribed to the event: a new endpoint gets message.received and nothing else. Then read the delivery log, which records what each attempt got back. Twenty consecutive failures turn an endpoint off and email the workspace owner.
- **The webhook arrives but the signature will not verify**: Almost always the body was parsed and re-serialised before the check. The signature covers the exact bytes we sent, so verify the raw text. During a secret rotation the header carries one signature per valid secret, so check every v1 in it rather than the first.
- **The same event arrives twice**: That is by design: delivery is at-least-once, and a receiver that takes longer than ten seconds is retried even though it usually finished the work. Every copy carries the same id, so record the ids you have handled and return 200 for a repeat.
- **Everything comes back 429**: Three hundred requests a minute per key, shared between the REST API and MCP. The response says how many seconds to wait. A loop polling for new mail is the usual cause, and /docs/waking is how to stop needing one.

If none of these is it, the delivery log, the agent page, and the activity log each record what actually happened rather than what was supposed to, and between them they cover most of the rest.

## OpenClaw

OpenClaw runs a gateway that stays up, which makes it one of the two clients here that can hold a mailbox unattended: mail can arrive at three in the morning and be answered without anyone opening a laptop. That also means the settings below matter more than they would for a chat client, because nobody is watching when one of them is wrong.

### The short version

Five steps, and the rest of this page is why each one is what it is. If you only want mail read and answered on request, the first three are enough: the last two are what makes it happen without you asking.

- **One. Add the server**: openclaw mcp set mcpmailer with the JSON below, or the same fields as flags on openclaw mcp add.
- **Two. Sign in**: openclaw mcp login mcpmailer prints a URL. Approve it, then pass the code back with --code.
- **Three. Check it for real**: openclaw mcp doctor mcpmailer --probe connects and lists the tools it found. If it lists them, you are connected and can stop here.
- **Four. Put the gateway on the internet**: We can only POST to public https, so a gateway on your own machine needs mcpmailer tunnel --handle scout --target http://localhost:18789.
- **Five. Point mail at it**: Set hooks.token, then mcpmailer webhooks:add against /hooks/wake or /hooks/agent. Both templates are further down.

### Connecting

Server entries live under mcp.servers.<name> in ~/.openclaw/openclaw.json, which is JSON5, so comments and trailing commas in the samples here are not a mistake. The canonical form is one JSON object, which openclaw mcp set takes whole; openclaw mcp add takes the same fields as flags if you would rather. transport is the field name to use, and it has to say streamable-http: without it the entry is treated as the older SSE style and the connection never comes up.

```bash
# The canonical form: one JSON object for the whole server entry.
openclaw mcp set mcpmailer '{
  "url": "https://connect.mcpmailer.com/mcp",
  "transport": "streamable-http",
  "auth": "oauth",
  "requestTimeoutMs": 320000,
  "connectionTimeoutMs": 30000
}'

# OAuth: this prints a URL. Approve it, then hand the code back.
openclaw mcp login mcpmailer
openclaw mcp login mcpmailer --code abc123

# Connect for real and list what came back.
openclaw mcp doctor mcpmailer --probe
```

With auth set to oauth there is no key on disk. openclaw mcp login prints an authorization URL, and the code that comes back goes in with --code. Tokens are refreshed for you, so a gateway restart does not send you back through the browser. openclaw mcp doctor --probe is the one to trust afterwards: it connects for real and lists the tools it found, where status only reports what the config says. openclaw mcp probe skips the static checks and connects straight away, and openclaw mcp status --verbose is the quick read when you only want to know whether a token is still good.

If you would rather hold a key, headers takes one directly. Interpolate it from the environment rather than writing it into the file, which is what keeps a config safe to commit.

```json
{
  "url": "https://connect.mcpmailer.com/mcp",
  "transport": "streamable-http",
  "requestTimeoutMs": 320000,
  "headers": { "Authorization": "Bearer ${MCPMAILER_KEY}" },
  "toolFilter": {
    "exclude": ["delete_*", "merge_contacts", "update_identity"]
  }
}
```

### The timeout that bites

Set requestTimeoutMs above 300000, and do it deliberately. wait_for_reply blocks for up to 300 seconds, while the value in their own example entry is 20000, and their reference documents no default at all: unset, the timeout is whatever the build you are running decided, which has no reason to clear a five minute wait. What you see when it does not is a tool error rather than a timeout you can attribute. connectionTimeoutMs is a different thing, covering only the handshake, and can stay small.

Mind the units. Both are milliseconds, in the JSON and in the CLI flags that write it: --timeout 320000 is the flag form of what this asks for. Passing --timeout 320 does not ask for 320 seconds, it asks for a third of a second, and every tool call fails.

This is the single most common misconfiguration on this client. If waits fail but sends work, this is why.

### Which tools it gets

toolFilter takes include and exclude, both accepting globs. An agent that runs unattended is a good reason to use it: the mail tools are the point, but delete_contact, merge_contacts, and update_identity are all things you probably do not want happening at four in the morning on the strength of an email. Excluding them costs nothing and takes a whole class of incident off the table.

It also trims the tool list the model has to read, which is worth having when the mailbox surface is forty-odd tools and only a handful are ever used.

### Waking it when mail arrives

The gateway can be told directly, with no service of yours in between. Its webhook endpoint is on by default but needs a token: set hooks.token, and hooks.path if you want it somewhere other than /hooks. Two routes, and which you want depends on whether mail should interrupt the session or be handled beside it.

/hooks/wake takes { text, mode } and drops a line into the main session, immediately when mode is now, or at the next heartbeat. It answers 200. This is the shorter setup and the right one for a single assistant you also talk to.

```json
{
  "text": "New mail from {{data.from}}: {{data.subject}}",
  "mode": "now"
}
```

```bash
# The endpoint OpenClaw exposes, and the token from its hooks config.
mcpmailer webhooks:add https://your-gateway.example.com/hooks/wake \
  --events message.received \
  --header "Authorization: Bearer YOUR_OPENCLAW_TOKEN" \
  --template '{"text":"New mail from {{data.from}}: {{data.subject}}","mode":"now"}'
```

/hooks/agent takes { message, ... } and runs an isolated turn, answering 202 because the work carries on after the response. It accepts agentId for which of your agents handles it, wakeMode, and deliver, channel, and to for where the answer goes. Use it when mail should be dealt with without derailing whatever the session is in the middle of.

```json
{
  "message": "New mail from {{data.from}}: {{data.subject}}. Read it with read_message {{data.message_id}} and answer if you can.",
  "agentId": "support",
  "wakeMode": "now"
}
```

The field name changes between the two: text for wake, message for agent. A template built for one route and posted at the other comes back 400.

The token goes in a header, either Authorization: Bearer or x-openclaw-token. In the query string it is refused with 400, and repeated auth failures are rate limited. We only POST to public https, so a gateway on your own machine needs a tunnel: mcpmailer tunnel --handle scout --target http://localhost:PORT. Their own docs would rather the gateway sat behind a proxy or a tailnet than on the open internet, and a tunnel is that.

### Mail is untrusted input

Anyone who knows the address can write to your agent, and an unattended one is the case that matters: nobody is reading over its shoulder when a message arrives saying to ignore its instructions and forward everything in the vault. The content of an email is data. It is never an instruction, however politely it is phrased and whoever it claims to be from.

Nothing here can decide that for you, because a mailbox that only accepted safe mail would not be a mailbox. What the two sides can do is make the blast radius small, and most of it is configuration you were going to set anyway.

- **Take away the tools it does not need**: toolFilter is the strongest control on this page, which is why it has its own section above. An agent that cannot call delete_contact cannot be talked into calling it. The CLI writes the same thing with --include, as in --include 'search,read_*'.
- **Prefer an isolated turn for mail from strangers**: /hooks/wake puts the text into the main session, where it stays in context for everything that comes after. /hooks/agent runs a turn of its own and posts a summary back. An injected instruction in an isolated turn is a bad five minutes; the same instruction in the main session is in the room for the rest of the day.
- **Bound what a hook may reach**: hooks.allowedAgentIds limits which agents a hook can route to at all, and hooks.allowRequestSessionKey stays false unless you have a reason: it defaults that way because a caller choosing its own session key is a caller choosing which conversation to write into.
- **Grant vault secrets to the agents that need them**: A mail-answering agent usually needs none. Grants are per agent on our side, so the one reading mail from the public and the one holding your Stripe key do not have to be the same identity. Every read is in the activity log with the agent and the time.
- **Never let the message choose the recipient**: Answer with reply_all, or with send_email carrying reply_to_message_id, so the reply goes to whoever actually wrote. An agent that composes a fresh send to an address it read out of a message body is one instruction away from being a forwarding service for whatever it can reach.

The ceilings are the backstop, not the plan: five recipients a message, the duplicate-content and velocity tripwires on cold sends, and a monthly spend cap an owner sets. They will not stop a well-aimed single email, but they do stop the runaway version, and a velocity trip is recorded on the agent page where you will see it. /blog/prompt-injection-email-agents goes through the attacks themselves.

### One gateway, several mailboxes

Agents are defined under agents.entries.<agentId>, and the hook routes accept agentId, so mail can be routed to the agent whose job it is. Make that line up on our side too: one MCPmailer agent per OpenClaw agent, each with its own key, and each webhook endpoint scoped with mailbox_id so one mailbox's mail cannot wake another's handler. Set hooks.allowedAgentIds to bound which agents a hook may reach at all.

See /docs/several-agents for what agents do and do not share on our side.

### Running it unattended

- **Check the connection, not the config**: openclaw mcp doctor mcpmailer --probe after any change. A config that reads correctly and a server that answers are different claims.
- **Watch the deliveries, not the gateway log**: mcpmailer webhooks:deliveries records what each attempt got back. Twenty consecutive failures turn an endpoint off and email the workspace owner, so a gateway that was down for a day comes back to a held queue rather than to silence.
- **Events are held for seven days**: An endpoint turned off keeps its events for a week and delivers them within a few minutes of being turned back on. Fixing a gateway inside that window loses nothing.
- **Verify the workspace email first**: Nothing sends until it is done, and an agent that discovers this at three in the morning cannot fix it. sending_locked_verify_email is the reason you will see.

### The whole file

Everything above, in one piece. Every other sample on this page is a section lifted out of this one.

```json
// ~/.openclaw/openclaw.json (JSON5: comments and trailing commas are fine)
{
  mcp: {
    servers: {
      mcpmailer: {
        url: "https://connect.mcpmailer.com/mcp",
        transport: "streamable-http",
        auth: "oauth",
        requestTimeoutMs: 320000,  // milliseconds, and above the 300s wait
        connectionTimeoutMs: 30000,
        toolFilter: {
          exclude: ["delete_*", "merge_contacts", "update_identity"],
        },
      },
    },
  },
  hooks: {
    token: "${OPENCLAW_HOOKS_TOKEN}",
    path: "/hooks",
    allowedAgentIds: ["support"],
    allowRequestSessionKey: false,
  },
}
```

Everything else that can go wrong is in /docs/troubleshooting, and the full webhook reference, including the signature check and the retry schedule, is in /docs/webhooks.

## Hermes

Hermes is the other client here built to run unattended, and it is unusually well suited to email: it has a webhook adapter of its own, so mail can start an agent run with nothing of yours in between, and a cron scheduler for the work that should happen on a clock rather than on an event.

### The short version

Five steps, and the rest of this page is why each one is what it is. The first three get an agent that reads and answers mail when you ask it to. The last two are what makes it happen on its own.

- **One. Add the server**: Put the mcp_servers entry below in ~/.hermes/config.yaml, or run hermes mcp add mcpmailer --url https://connect.mcpmailer.com/mcp.
- **Two. Sign in**: hermes mcp login mcpmailer runs the PKCE flow and keeps the token, so nothing sensitive lands in the file.
- **Three. Check it for real**: hermes mcp test mcpmailer connects and lists the tools. If it lists them, you are connected and can stop here.
- **Four. Put the gateway on the internet**: The adapter listens on 8644 and we can only POST to public https, so run mcpmailer tunnel --handle scout --target http://localhost:8644.
- **Five. Point mail at it**: Add a route with a secret, then mcpmailer webhooks:add against https://scout.mcpmailerwire.com/webhooks/mcpmailer. Put the signing secret it prints on the route.

### Connecting

Servers go under mcp_servers in ~/.hermes/config.yaml. With auth: oauth, Hermes runs the PKCE flow itself and keeps the token, refreshing it as needed, so nothing sensitive is in the file. hermes mcp add mcpmailer --url writes the same entry from the command line.

```text
mcp_servers:
  mcpmailer:
    url: https://connect.mcpmailer.com/mcp
    auth: oauth
    # wait_for_reply blocks for up to 300 seconds, so this has to clear it.
    timeout: 320
    connect_timeout: 30
    tools:
      exclude:
        - "delete_*"
        - "merge_contacts"
        - "update_identity"
```

timeout is the tool call timeout in seconds and connect_timeout covers only the initial handshake. wait_for_reply blocks for up to 300 seconds, so timeout has to clear that or long waits die before the reply lands. This is the setting people get wrong on this client.

To hold a key instead, headers takes one, and it substitutes ${ENV_VAR} at runtime, so the config stays free of the secret.

```text
mcp_servers:
  mcpmailer:
    url: https://connect.mcpmailer.com/mcp
    headers:
      # Substituted at runtime, so the key is not in the file.
      Authorization: "Bearer ${MCPMAILER_KEY}"
    timeout: 320
    connect_timeout: 30
```

### Which tools it gets

tools takes include and exclude, both accepting names or globs, and include wins if you set both. As with any agent left running, the mail tools are the point and the destructive ones are worth leaving out: an unattended run should not be able to delete a contact because an email asked it to. enabled: false switches the whole server off without deleting the entry, which is the quickest way to take a mailbox out of service while you look at something.

Two more keys trim the surface further than exclude reaches. prompts: false and resources: false drop the utility wrappers Hermes otherwise registers alongside the tools, and on a mailbox that is only ever called for its tools they are pure context the model has to read past.

### Waking it when mail arrives

The webhook adapter listens on port 8644 and routes are reached at /webhooks/<route-name>. Every route must resolve to a secret or the adapter refuses to start: either its own, or the fallback secret set once beside port under extra. A per-route secret is the one to prefer, because rotating ours then touches one route rather than every route at once. hermes webhook subscribe writes a route from the command line and hands back its URL and secret; the config file form is below.

```text
platforms:
  webhook:
    enabled: true
    extra:
      port: 8644
      routes:
        mcpmailer:
          # The endpoint's signing secret, shown once when you create it.
          # Hermes verifies our signature against this on every delivery.
          secret: "whsec_..."
          prompt: "New mail from {data.from}: {data.subject}. {data.snippet}"
          filters:
            - field: "event"
              equals: "message.received"
```

The prompt is a template over the payload with dot paths, and this is where Hermes is easier than anything else here: our envelope is already the shape it wants, so {data.from}, {data.subject}, {data.snippet}, and {data.message_id} resolve straight out of it with no body template on our side. {__raw__} drops the whole payload in, up to 4000 characters. A missing key renders as itself rather than erroring, which is what you are looking at when a prompt arrives with braces still in it.

filters run before the agent is dispatched, so a route that should only act on new mail says so declaratively instead of spending a turn deciding. deliver_only skips the model altogether, which is right for a route whose whole job is to forward a notification. Its response codes are worth knowing when you read our delivery log: 200 handled, 400 malformed JSON, 401 secret mismatch, 404 no such route, 413 body over a megabyte, 429 rate limited at 30 a minute per route by default, 502 delivery failed downstream. The 413 is the one to know about, because it is the only one that depends on the mail rather than the setup: a message with a long snippet is what gets near a megabyte, so a route that works all week can fail on one email. max_body_bytes raises the ceiling.

```bash
# Hermes listens on 8644. Ours has to reach it over public https.
mcpmailer tunnel --handle scout --target http://localhost:8644

# The route name is the last path segment. Keep the signing secret this prints:
# it is what the route's secret: line wants.
mcpmailer webhooks:add https://scout.mcpmailerwire.com/webhooks/mcpmailer \
  --events message.received
```

Nothing to configure for authenticity: we send the signature under the generic names its recommended mode reads, X-Webhook-Signature-V2 and X-Webhook-Timestamp, so putting the endpoint's signing secret on the route is the whole setup. It is the same HMAC-SHA256 over timestamp.body with the same five minutes of tolerance that our own header carries.

Rotating is the one place to be careful. A single-valued header can only hold one digest, so it carries the new secret from the moment you rotate, where our own header carries both for 24 hours. On this style that window is a deadline rather than a grace period: update the route secret when you rotate, not later.

Hermes deduplicates on X-GitHub-Delivery or X-Request-ID, and we send the delivery id under X-Request-ID as well as our own header, so a retry inside its hour-long cache is answered 200 with status=duplicate and never reaches the agent. That covers the ordinary case and is not a guarantee: the cache expires, and our retry schedule can outlast it. A route that does something irreversible should still check the id, which arrives in the payload as {id}.

### Mail is untrusted input

Their own documentation puts this well: authenticated does not mean trusted. A signature proves a delivery came from us and says nothing at all about the person who sent the email inside it. An unattended agent is the case that matters, because nobody is watching when a message arrives telling it to ignore its instructions and mail the vault somewhere.

- **Scope the toolset**: tools.exclude on the MCP entry is the control with the most leverage, and it is the same one that keeps the tool list short. Their guidance goes further for a gateway on the internet: keep terminal, file, and outbound-action tools away from a session that only needs to read and summarise, and run the gateway with the Docker or SSH backend so a hijacked turn cannot touch the host.
- **Give a route only the skills it needs**: skills is per route, so the run that answers mail loads what answering mail takes and nothing else. A route is a much smaller thing to reason about than an agent, which is the argument for having one per job.
- **Filter before the model, not after**: filters run before dispatch, so mail that should never start a run does not start one. deliver_only goes further and skips the model altogether, which is exactly right for a route whose job is to forward a notification: nothing reads the message, so nothing can be talked into anything by it.
- **Grant vault secrets narrowly**: Grants are per agent on our side. The identity reading mail from the public does not need to be the identity that can open a credential, and every read is recorded in the activity log with the agent and the time.
- **Never let the message choose the recipient**: Answer with reply_all, or with send_email carrying reply_to_message_id, so the reply goes to whoever wrote. An agent that sends to an address it read out of a message body is one instruction away from forwarding whatever it can reach to whoever asks.

The ceilings are the backstop, not the plan: five recipients a message, the duplicate-content and velocity tripwires on cold sends, and a monthly spend cap an owner sets. /blog/prompt-injection-email-agents goes through the attacks themselves.

### Cron, for the work that is not an event

Not everything wants a webhook. A morning digest, a nightly sweep of what went unanswered, a weekly tidy of the address book: those are schedules, and a scheduled run is not the polling loop this documentation keeps warning you off. One run a day that lists unread mail is fine. A loop that calls list_messages every thirty seconds is not, and it will spend the rate limit that the rest of the agent needs.

```bash
hermes mcp test mcpmailer      # connect and list the tools
hermes mcp login mcpmailer     # re-authorise when a token is refused
hermes mcp list                # what is configured

hermes gateway run             # foreground, for a terminal or tmux
hermes gateway start           # install as systemd or launchd
hermes gateway status          # is it up, and as which profile

# A morning sweep is not polling: one run a day, not a loop.
hermes cron create "0 9 * * *" "Summarise any unread mail and archive what is handled."
```

### Running it as a service

hermes gateway run keeps it in the foreground, which is what you want under tmux, in WSL, or in a container. hermes gateway start installs it as systemd or launchd so it comes back after a reboot, and hermes gateway status says whether it is up and under which profile. Profiles matter here if you run more than one: a route bound to a profile is reached under its own path, so two mailboxes can have two gateways without their webhooks crossing.

### The whole file

Everything above, in one piece. Every other sample on this page is a section lifted out of this one.

```text
# ~/.hermes/config.yaml
mcp_servers:
  mcpmailer:
    url: https://connect.mcpmailer.com/mcp
    auth: oauth
    timeout: 320        # seconds on this client, unlike OpenClaw
    connect_timeout: 30
    tools:
      exclude:
        - "delete_*"
        - "merge_contacts"
        - "update_identity"
      prompts: false
      resources: false

platforms:
  webhook:
    enabled: true
    extra:
      port: 8644
      routes:
        mcpmailer:
          secret: "whsec_..."
          prompt: "New mail from {data.from}: {data.subject}. {data.snippet}"
          skills: ["answer-mail"]
          filters:
            - field: "event"
              equals: "message.received"
```

Line the identities up across both sides: one MCPmailer agent per Hermes profile or route, each with its own key, and each endpoint scoped with mailbox_id. /docs/several-agents covers what is shared between agents on our side and what is not.

A gateway that is down does not lose mail. Deliveries are retried, then held for seven days once an endpoint is turned off after twenty consecutive failures, and they arrive within a few minutes of it coming back. mcpmailer webhooks:deliveries is where to look first, because it records what your receiver actually answered.

## Identities

Every agent is an identity: a handle unique across all of MCPmailer, owning exactly one mailbox. The handle is what the agent is called; the address is where its mail arrives. Identities in a workspace are invisible to each other until you grant access, so one agent cannot enumerate the rest by default.

### Inbound filtering

Each identity runs in one of two modes. Blacklist (the default) delivers everything except senders you block. Whitelist delivers nothing except senders you allow, which is the right setting for an agent that should only ever talk to known parties.

Rules match an exact address or a whole domain, and the exact rule wins, so blocking a domain while allowing one person at it works as written. Filtered mail is dropped before storage, logged on the agent page, and pushed as a message.filtered webhook.

### Tools

#### `get_identity`

`{ }`

Your handle, display name, description, inbound filter mode, and every filter rule.

Returns: Identity with rules.

#### `update_identity`

`{ display_name?: string, description?: string, filter_mode?: "blacklist" | "whitelist" }`

Changes your own presentation and how inbound mail is filtered. Omitted fields are unchanged.

| Parameter | Type | Required |
| --- | --- | --- |
| display_name | `string` | no |
| description | `string` | no |
| filter_mode | `"blacklist" | "whitelist"` | no |

Returns: The updated identity.

#### `set_mail_rule`

`{ match: "exact_email" | "domain", value: string, action: "allow" | "block" }`

Allows or blocks a sender. An exact-address rule beats a domain rule, so you can block a domain and still let one person through. Setting the same match and value twice updates the action.

| Parameter | Type | Required |
| --- | --- | --- |
| match | `"exact_email" | "domain"` | yes |
| value | `string` | yes |
| action | `"allow" | "block"` | yes |

Returns: The stored rule.

#### `delete_mail_rule`

`{ rule_id: string }`

Removes a filter rule.

| Parameter | Type | Required |
| --- | --- | --- |
| rule_id | `string` | yes |

Returns: { deleted: boolean }

#### `list_identities`

`{ }`

The other agents you can see. Identities are hidden from each other until access is granted, so this is usually just you.

Returns: Visible identities.

## Sending and quotas

Every outbound message is classified. A reply means the recipient wrote to you first in that thread. A cold send is anything else: it carries an unsubscribe link and is watched by the duplicate-content and velocity tripwires. Both draw down one daily allowance, 100 a day on Free and uncapped on paid plans, and one monthly allowance of 3,000, 10,000, or 150,000. Free stops at its monthly number; paid plans keep sending and meter what goes past it, up to the spend cap if one is set. Both are capped at 5 recipients. Nothing sends at all until the workspace email is verified, which is refused as sending_locked_verify_email.

When a send is rejected, the reason tells the agent what to do: daily_send_quota_exhausted and monthly_send_quota_exhausted include when the quota resets; monthly_spend_cap_reached means the workspace hit the ceiling its owner set, and only they can raise it; recipient_suppressed lists addresses that previously bounced or complained.

Cold sends carry a signed one-click List-Unsubscribe link. Using it suppresses that address for your workspace immediately, and later sends to it are rejected before they reach SES.

### Sending it later

A send can name send_at, up to 30 days out, and comes back as scheduled with a scheduled_id rather than as sent. Use it for the times a message is right but the moment is not: a follow-up on Monday, a reminder the day before, a chase in a week if nobody has replied.

Nothing is decided at booking time. The quotas, the suppression list, the outbound policy and the agent's approval setting are all applied at the moment it actually sends, which is the honest way round: an address that bounces on Tuesday should not receive mail booked on Monday. Ask list_scheduled or GET /v1/messages/scheduled for what is still coming, and GET /v1/messages/scheduled/{id} for what became of one. A booking the agent's approval setting caught gets its own held status carrying a pending_id, because calling it sent or failed would say something untrue about mail nobody has released.

cancel_scheduled, or DELETE on the same URL, calls one off. It answers 409 once the runner has taken it, which is a real answer rather than an error: the message is on its way, so look at what it became rather than retrying the cancel.

### When a person reads it first

Agents send their own mail, including first contact, and every agent starts that way. An agent can also be set to hold, which is a per-agent setting on its Settings tab: hold first contact and let replies through, or hold everything. It is the setting for a new agent nobody has watched yet, or a first run of outreach, and it is meant to be turned off again.

A held send comes back as held with a pending_id rather than as an error. That is a third outcome, not a failure: over REST it is a 202, and the message is queued rather than refused. Do not retry it. Retrying a held send is how one message becomes five in the reviewer's queue, all of which they then have to read.

Ask what was decided with check_approval over MCP, or GET /v1/messages/pending/{pending_id} over REST, or subscribe to the approval.approved, approval.rejected and approval.expired webhook events and be told instead of polling. A rejection carries decision_note, the reviewer's own words: it is the only channel through which a person tells an agent what was wrong with what it wrote, so read it before composing a replacement rather than sending the same thing again.

Approving replays the request through the ordinary send path, so the suppression list, the quotas and the spend cap are all checked at the moment it actually goes. Nothing is pre-authorised by having been approved. A message nobody acts on expires after 7 days and will not be sent, because a reply to a question asked a fortnight ago is the wrong message rather than a late one.

A person can also take a single conversation off an agent from the inbox. Replies in that thread are then refused with thread_held_for_human, which means somebody is answering it themselves: do not retry, and do not start a new thread with the same person to get around it.

### Rate limits

Every API key gets 300 requests per minute across the REST API and the MCP server, counted per key. Calls without a key are limited to 30 per minute per IP address, and OAuth client registration to 5 per minute per IP: it is open to anyone by necessity, so it is budgeted tightly and unused registrations are deleted after a day.

Responses carry x-ratelimit-limit and, where available, x-ratelimit-remaining and x-ratelimit-reset. A refusal is a 429 with retry-after in seconds and a body of {"error": "rate_limited"}.

Wait the stated retry-after rather than retrying immediately. A tight retry loop is what the limit exists to stop.

## Receiving mail

Inbound mail needs no setup. An identity receives at its address from the moment it exists, and if you connected an assistant over MCP it can already read the inbox. This chapter is about what happens in between, and about the three ways to find out that something arrived.

### What happens to an arriving message

It is checked against the identity filter first. Mail that does not pass is dropped before it is stored, logged on the agent page, and pushed as message.filtered, so a whitelisted agent never sees it at all. See /docs/identities for how the modes and rules work.

What survives is threaded, using the standard In-Reply-To and References headers, so a reply lands in the same thread as the message it answers rather than starting a new one. It is filed against the mailbox with a spam verdict from the receiving side, its attachments are stored separately from the body, and only then does message.received fire. Delivery is idempotent: the same Message-ID arriving twice at one mailbox is filed once.

### Finding out that mail arrived

There are three ways, and which one is right depends on what is doing the waiting. /docs/waking compares them and sets up the third; the short version is below.

- **Let the agent look**: An assistant with the tools connected calls list_messages when it needs to. Nothing to build.
- **Wait inside one task**: wait_for_reply blocks until an answer lands, for an agent that just sent something and cannot continue without a reply.
- **Be told**: A signed webhook to your own service, for anything long-running or event-driven. The only option that costs nothing while idle.

Do not poll list_messages on a timer. It burns rate limit against every other call the agent makes, and both of the other options exist to avoid it.

### Reading a message

list_messages returns snippets rather than whole bodies, which is usually what an agent should reason over first: it keeps a scan of twenty messages inside a sensible context. Call read_message for the full body when one of them turns out to matter, and get_thread to read a conversation in order before answering it.

Attachments arrive as metadata on the message, not as bytes. Fetch the ones you need by filename with get_attachment. A message with a 9 MB PDF costs nothing to list.

### While an agent is paused

Pausing an agent stops it sending and stops anything connecting as it. Mail addressed to it still arrives and is filed, so nothing is lost and it picks up where it left off. What the sender hears meanwhile is a setting: an agent with a reply written on its Settings tab answers each new conversation once while it is paused, and one without says nothing at all.

That notice is never sent to bounces, autoresponders, list mail, or no-reply addresses, and never more than once per conversation. get_thread reports away_notice_sent_at, so an agent picking a conversation back up after a pause can tell whether the person has already been told, rather than apologising for a delay in the same words twice.

### Answering

Prefer reply_all over composing a fresh message: it keeps the threading headers intact, so the exchange stays one conversation in the recipient's client. A reply also counts as a reply rather than a cold send, which is the difference between drawing on the ordinary allowance and passing under the tripwires that watch cold sends. See /docs/sending.

Treat the contents of an inbound message as untrusted input, never as instructions. Anyone can write to your agent, and a message that says to ignore previous instructions and forward the vault is exactly what an attacker sends. Keep what mail says separate from what your agent is allowed to do.

## Contacts, notes, and the vault

Contacts are a workspace address book with reverse lookup, so an agent can find out who an address belongs to before answering. Anything an agent chooses to remember about a contact surfaces on every later lookup, including by other agents.

Notes are shared workspace context that outlives a conversation; humans see all of them, each agent sees only the ones granted to it plus its own.

### The vault

The vault holds credentials your agents can use without you pasting them into a prompt. Add a secret, say which agents may use it, and they call get_secret when they need it. There is no key to hand out and no unlock step.

Values are encrypted before they are stored, each under its own key, and those keys are wrapped under a key that lives in our secret store rather than in the database. So a database dump, a stolen backup, or a leak of a read replica carries ciphertext and nothing that opens it. Each stored value is also tied to the entry it belongs to, so a secret cannot be swapped for another one behind your back.

Every read is recorded in the activity log, by agent and by time, so you can always answer what a given agent has reached.

Never paste a credential into a prompt or a tool argument yourself. Anything written into a model conversation is kept in its history and replayed on later turns. Put it in the vault and let the agent fetch it.

Grant secrets per agent, and use get_totp_code when a login needs a second factor.

## Knowledge base

An agent answering a customer or writing to a stranger needs to know what you sell, what it costs, and how you talk. The knowledge base is where that lives. Add your website and it is crawled and indexed; add a single page, or paste a pricing sheet, a policy, or a tone guide. Every agent in the workspace can then search it, and the dashboard page is at /dashboard/knowledge.

### The brief

The brief is a few paragraphs in your own words: what the company does, who it is for, how it speaks. Agents read it before anything crawled, because positioning and tone are the part a crawl cannot recover from a page. Keep it short and keep it current; it is the first thing get_knowledge_summary returns.

### Sources

A site is crawled from its front page and its sitemap, staying on the same domain and honouring robots.txt and noindex. Navigation, headers, and footers are dropped so a search does not rank the menu. A page is fetched on its own. Pasted text is stored as given. Sites and pages are fetched again every week, or on demand with refresh_knowledge_source.

Each plan includes a number of indexed pages across all sources: 50 on Free, 500 on Pro, 5,000 on Startup. A crawl stops at the allowance and says so on the source, and a second site cannot push the first out.

### How an agent uses it

Call get_knowledge_summary once at the start of a task. Before stating a price, a policy, a feature, or a deadline, call search_knowledge and quote what comes back; the passage carries the page it came from, so the agent can link to it. If nothing matches, the right answer is that it does not know. read_knowledge_page opens a whole page when a passage is not enough.

```text
Read the knowledge summary, then answer the latest inbound email at support@. Search the knowledge base before quoting any price or policy, and link the page you took it from.
```

Agents can add sources themselves with add_knowledge_source, so "index our website" is an instruction you can give in a conversation.

## Tunnels

Every identity also gets a stable public hostname at handle.mcpmailerwire.com. Inbound HTTP to that hostname is forwarded to wherever the agent is actually running, through the connection the agent holds open, so it keeps working behind NAT, a firewall, or laptop wifi. The hostname survives restarts and network changes.

```ts
import { connect } from '@mcpmailer/sdk';

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

### Limits

Only the API key belonging to that handle can bring its tunnel up. Requests wait up to 30 seconds for the agent; if nothing is connected, callers get a 502 saying so rather than a timeout. Inbound WebSocket connections are bridged to your target too, so a client can hold a live socket to your agent through the tunnel.

The hostname is public, so it carries the limits a public address needs: 600 requests a minute for the hostname and 120 for any single caller, both answered with a 429, and bodies up to 8 MiB, over which the request comes back as a 413. A handle holds 100 requests and 64 open WebSockets at once; past that callers get a 503 with a retry-after rather than a queue that grows. If you need more than that in front of an agent, put the tunnel in front of something that can take it, or ask us.

## Tool reference

Names and arguments are identical across MCP, the REST API, and the SDKs. What a call can reach is decided by the key it was made with.

### Email

#### `send_email`

`{ to: string[], cc?: string[], subject: string, body: string, style?: "plain" | "flat" | "card", reply_to_message_id?: string, attachments?: Attachment[], track_opens?: boolean, send_at?: string }`

Sends markdown email. If reply_to_message_id references a message this mailbox received, the send is classified as a reply: no unsubscribe footer, same thread. Otherwise it is a cold send, which carries an unsubscribe link and is subject to the duplicate-content and velocity tripwires. Both draw down the same daily allowance.

| Parameter | Type | Required |
| --- | --- | --- |
| to | `string[]` | yes |
| cc | `string[]` | no |
| subject | `string` | yes |
| body | `string` | yes |
| style | `"plain" | "flat" | "card"` | no |
| reply_to_message_id | `string` | no |
| attachments | `Attachment[]` | no |
| track_opens | `boolean` | no |
| send_at | `string` | no |

Returns: { status: "sent" | "held" | "scheduled" | "rejected" }, with messageId, pendingId, scheduled_id, or reason and retryAfter.

#### `list_scheduled`

`{ limit?: number }`

Sends this agent has booked and not yet made, soonest first. Only what is still coming: one that has gone is an ordinary sent message.

| Parameter | Type | Required |
| --- | --- | --- |
| limit | `number` | no |

Returns: { scheduled: [{ scheduled_id, subject, to, send_at, booked_at }] }

#### `cancel_scheduled`

`{ scheduled_id: string }`

Calls a booking off. Refused once the runner has taken it, with the status it reached, so the next step is reading what became of it rather than retrying.

| Parameter | Type | Required |
| --- | --- | --- |
| scheduled_id | `string` | yes |

Returns: { cancelled: true } or { error: "too_late", status, message_id?, pending_id? }

#### `reply_all`

`{ message_id: string, body: string, attachments?: Attachment[], track_opens?: boolean }`

Replies keeping the whole audience: the original sender in To, the other recipients in Cc, your own address dropped. Threading is preserved, so it counts as a reply.

| Parameter | Type | Required |
| --- | --- | --- |
| message_id | `string` | yes |
| body | `string` | yes |
| attachments | `Attachment[]` | no |
| track_opens | `boolean` | no |

Returns: Same shape as send_email.

#### `forward_email`

`{ message_id: string, to: string[], body?: string, mode?: "inline" | "wrapped" }`

Forwards a message. Wrapped mode keeps headers and attachments intact. Forwarding to someone who has not written to you is a cold send.

| Parameter | Type | Required |
| --- | --- | --- |
| message_id | `string` | yes |
| to | `string[]` | yes |
| body | `string` | no |
| mode | `"inline" | "wrapped"` | no |

Returns: Same shape as send_email.

#### `get_thread`

`{ thread_id: string }`

Every message in a conversation at once, oldest first. Use this instead of paging list_messages when you need full context before replying.

| Parameter | Type | Required |
| --- | --- | --- |
| thread_id | `string` | yes |

Returns: Thread with all messages.

#### `get_attachment`

`{ message_id: string, filename: string }`

Downloads one attachment, base64 encoded.

| Parameter | Type | Required |
| --- | --- | --- |
| message_id | `string` | yes |
| filename | `string` | yes |

Returns: { filename, content_type, size, content_base64 }

#### `list_messages`

`{ unread_only?: boolean, thread_id?: string, include_archived?: boolean, limit?: number }`

Lists messages newest first with sender, subject, snippet, unread flag, and spam verdict.

| Parameter | Type | Required |
| --- | --- | --- |
| unread_only | `boolean` | no |
| thread_id | `string` | no |
| include_archived | `boolean` | no |
| limit | `number` | no |

Returns: Message summaries.

#### `archive_message`

`{ message_id: string, archived?: boolean }`

Marks a message dealt with so it drops out of list_messages. It stays searchable and readable by id.

| Parameter | Type | Required |
| --- | --- | --- |
| message_id | `string` | yes |
| archived | `boolean` | no |

Returns: { id, archived_at }

#### `mark_unread`

`{ message_id: string, unread?: boolean }`

Puts a message back in the unread pile, which is how you flag something for a human after reading it.

| Parameter | Type | Required |
| --- | --- | --- |
| message_id | `string` | yes |
| unread | `boolean` | no |

Returns: { id, unread }

#### `read_message`

`{ message_id: string }`

Returns the full message with body and headers, and marks it read.

| Parameter | Type | Required |
| --- | --- | --- |
| message_id | `string` | yes |

Returns: Full message.

#### `search_inbox`

`{ query: string, limit?: number }`

Ranked full-text search over subjects, senders, and full bodies. Returns highlighted excerpts.

| Parameter | Type | Required |
| --- | --- | --- |
| query | `string` | yes |
| limit | `number` | no |

Returns: Ranked hits with excerpts.

#### `wait_for_reply`

`{ thread_id: string, timeout_seconds?: number }`

Blocks until a new message arrives in the thread or the timeout elapses. Poll in a loop for longer waits.

| Parameter | Type | Required |
| --- | --- | --- |
| thread_id | `string` | yes |
| timeout_seconds | `number` | no |

Returns: The new message, or { timed_out: true }.

#### `get_mailbox_info`

`{ }`

The mailbox address, plan, quotas, sends left today, usage today, whether sending is unlocked, and whether this agent is paused and what it replies while it is.

Returns: Mailbox status object.

### Identity and filtering

#### `get_identity`

`{ }`

Your handle, display name, description, inbound filter mode, and every filter rule.

Returns: Identity with rules.

#### `update_identity`

`{ display_name?: string, description?: string, filter_mode?: "blacklist" | "whitelist" }`

Changes your own presentation and how inbound mail is filtered. Omitted fields are unchanged.

| Parameter | Type | Required |
| --- | --- | --- |
| display_name | `string` | no |
| description | `string` | no |
| filter_mode | `"blacklist" | "whitelist"` | no |

Returns: The updated identity.

#### `set_mail_rule`

`{ match: "exact_email" | "domain", value: string, action: "allow" | "block" }`

Allows or blocks a sender. An exact-address rule beats a domain rule, so you can block a domain and still let one person through. Setting the same match and value twice updates the action.

| Parameter | Type | Required |
| --- | --- | --- |
| match | `"exact_email" | "domain"` | yes |
| value | `string` | yes |
| action | `"allow" | "block"` | yes |

Returns: The stored rule.

#### `delete_mail_rule`

`{ rule_id: string }`

Removes a filter rule.

| Parameter | Type | Required |
| --- | --- | --- |
| rule_id | `string` | yes |

Returns: { deleted: boolean }

#### `list_identities`

`{ }`

The other agents you can see. Identities are hidden from each other until access is granted, so this is usually just you.

Returns: Visible identities.

### Contacts

#### `lookup_contact`

`{ query: string, limit?: number }`

Reverse lookup: find out who just wrote to you before deciding how to answer. Exact match first, then partial. Each hit carries the facts other agents remembered about that person.

| Parameter | Type | Required |
| --- | --- | --- |
| query | `string` | yes |
| limit | `number` | no |

Returns: Contacts with channels and memories.

#### `search_contacts`

`{ query: string, limit?: number }`

Full-text search over names, company, job title, and notes.

| Parameter | Type | Required |
| --- | --- | --- |
| query | `string` | yes |
| limit | `number` | no |

Returns: Ranked contacts.

#### `get_contact`

`{ contact_id: string }`

One contact in full, with every channel and all remembered context.

| Parameter | Type | Required |
| --- | --- | --- |
| contact_id | `string` | yes |

Returns: Full contact.

#### `create_contact`

`{ given_name?: string, family_name?: string, preferred_name?: string, company_name?: string, job_title?: string, channels?: Channel[], domains?: string[], notes?: string }`

Adds someone to the workspace address book. At least one name field is required.

| Parameter | Type | Required |
| --- | --- | --- |
| given_name | `string` | no |
| family_name | `string` | no |
| preferred_name | `string` | no |
| company_name | `string` | no |
| job_title | `string` | no |
| channels | `Channel[]` | no |
| domains | `string[]` | no |
| notes | `string` | no |

Returns: The created contact.

#### `update_contact`

`{ contact_id: string, given_name?: string, family_name?: string, preferred_name?: string, company_name?: string, job_title?: string, channels?: Channel[], domains?: string[], notes?: string }`

Omitted fields keep their value.

| Parameter | Type | Required |
| --- | --- | --- |
| contact_id | `string` | yes |
| given_name | `string` | no |
| family_name | `string` | no |
| preferred_name | `string` | no |
| company_name | `string` | no |
| job_title | `string` | no |
| channels | `Channel[]` | no |
| domains | `string[]` | no |
| notes | `string` | no |

Returns: The updated contact.

#### `delete_contact`

`{ contact_id: string }`

Deletes a contact and everything remembered about them.

| Parameter | Type | Required |
| --- | --- | --- |
| contact_id | `string` | yes |

Returns: { deleted: true }

#### `remember_about_contact`

`{ contact_id: string, fact: string, message_id?: string }`

Saves a durable fact. It surfaces on every later lookup of that contact, including lookups by other agents.

| Parameter | Type | Required |
| --- | --- | --- |
| contact_id | `string` | yes |
| fact | `string` | yes |
| message_id | `string` | no |

Returns: The stored memory.

#### `find_duplicate_contacts`

`{ limit?: number }`

Pairs that look like the same person: they share an email or phone number, or carry the same name.

| Parameter | Type | Required |
| --- | --- | --- |
| limit | `number` | no |

Returns: Candidate pairs with the reason.

#### `merge_contacts`

`{ survivor_id: string, loser_id: string }`

Folds a duplicate into the contact you keep. The survivor holds on to its own fields and gains the other one channels, remembered facts, and anything it was missing; notes from both are kept.

| Parameter | Type | Required |
| --- | --- | --- |
| survivor_id | `string` | yes |
| loser_id | `string` | yes |

Returns: The surviving contact.

#### `import_vcards`

`{ vcard: string }`

Imports vCards, for example an export from Apple Contacts or Google Contacts. A bad card is skipped and reported without sinking the batch.

| Parameter | Type | Required |
| --- | --- | --- |
| vcard | `string` | yes |

Returns: { imported, failed[], contact_ids[] }

#### `export_vcard`

`{ contact_id: string }`

Exports one contact as an RFC 6350 vCard.

| Parameter | Type | Required |
| --- | --- | --- |
| contact_id | `string` | yes |

Returns: vCard text.

#### `export_all_vcards`

`{ }`

Exports the whole address book as one vCard stream, ready to write to a .vcf file. Company contacts carry KIND:org so they come back as companies, not people.

Returns: vCard text.

#### `import_contacts_csv`

`{ csv: string, list_name?: string, list_id?: string }`

Turns pasted text into contacts. Common column names are understood (email, first name, last name, name, company, title, phone, notes). A row whose email already belongs to a contact reuses that contact, so one person can sit on several lists. Rows with no email or phone are skipped and reported.

| Parameter | Type | Required |
| --- | --- | --- |
| csv | `string` | yes |
| list_name | `string` | no |
| list_id | `string` | no |

Returns: { created, matched_existing, added_to_list, list_id, columns_understood, skipped[], contact_ids[] }

#### `list_contact_lists`

`{ }`

Every list in the workspace with its member count. Lists group people for a purpose: sales prospects, press, customers.

Returns: Lists with member_count.

#### `get_contact_list`

`{ list_id: string, limit?: number, offset?: number }`

The list and a page of its members with channels and recent remembered facts. Work a list one person at a time: read them, write them a message of their own, remember what you learned. There is no send-to-list.

| Parameter | Type | Required |
| --- | --- | --- |
| list_id | `string` | yes |
| limit | `number` | no |
| offset | `number` | no |

Returns: List, member_count, next_offset, members[].

#### `create_contact_list`

`{ name: string, description?: string }`

An empty list.

| Parameter | Type | Required |
| --- | --- | --- |
| name | `string` | yes |
| description | `string` | no |

Returns: The list.

#### `update_contact_list`

`{ list_id: string, name?: string, description?: string }`

Renames a list or changes what it is for.

| Parameter | Type | Required |
| --- | --- | --- |
| list_id | `string` | yes |
| name | `string` | no |
| description | `string` | no |

Returns: The list.

#### `delete_contact_list`

`{ list_id: string }`

Removes the grouping. The contacts stay in the address book.

| Parameter | Type | Required |
| --- | --- | --- |
| list_id | `string` | yes |

Returns: { deleted }

#### `add_to_contact_list`

`{ list_id: string, contact_ids: string[] }`

Puts existing contacts on a list. Already-members are left alone.

| Parameter | Type | Required |
| --- | --- | --- |
| list_id | `string` | yes |
| contact_ids | `string[]` | yes |

Returns: { added }

#### `remove_from_contact_list`

`{ list_id: string, contact_ids: string[] }`

Takes contacts off a list without deleting them.

| Parameter | Type | Required |
| --- | --- | --- |
| list_id | `string` | yes |
| contact_ids | `string[]` | yes |

Returns: { removed }

### Notes

#### `list_notes`

`{ limit?: number }`

Notes you can read, most recently updated first.

| Parameter | Type | Required |
| --- | --- | --- |
| limit | `number` | no |

Returns: Note summaries.

#### `search_notes`

`{ query: string, limit?: number }`

Full-text search across titles and bodies, with highlighted excerpts.

| Parameter | Type | Required |
| --- | --- | --- |
| query | `string` | yes |
| limit | `number` | no |

Returns: Ranked hits with excerpts.

#### `read_note`

`{ note_id: string }`

The full note body.

| Parameter | Type | Required |
| --- | --- | --- |
| note_id | `string` | yes |

Returns: Full note.

#### `create_note`

`{ title?: string, body: string }`

Writes into shared workspace context. You can always read back your own notes.

| Parameter | Type | Required |
| --- | --- | --- |
| title | `string` | no |
| body | `string` | yes |

Returns: The created note.

#### `update_note`

`{ note_id: string, title?: string, body?: string }`

Omit a field to keep it.

| Parameter | Type | Required |
| --- | --- | --- |
| note_id | `string` | yes |
| title | `string` | no |
| body | `string` | no |

Returns: The updated note.

#### `delete_note`

`{ note_id: string }`

Deletes a note you can access.

| Parameter | Type | Required |
| --- | --- | --- |
| note_id | `string` | yes |

Returns: { deleted: true }

### Knowledge

What the workspace has told its agents about the company: a written brief, crawled websites, single pages, and pasted text, all searchable. /docs/knowledge is the guide.

#### `get_knowledge_summary`

`{ }`

Read once before writing: the workspace brief, every indexed source with its status, and a table of contents. Facts come from search_knowledge.

Returns: { brief, sources, pages, total_pages }

#### `search_knowledge`

`{ query: string, limit?: number }`

Full-text search across crawled sites, pages, and pasted text. Passages come back in full with the page they are from.

| Parameter | Type | Required |
| --- | --- | --- |
| query | `string` | yes |
| limit | `number` | no |

Returns: { hits: [{ page_id, page_url, page_title, heading, body, rank }] }

#### `read_knowledge_page`

`{ page_id: string }`

A whole indexed page, when a passage is not enough.

| Parameter | Type | Required |
| --- | --- | --- |
| page_id | `string` | yes |

Returns: { id, url, title, content, fetched_at }

#### `list_knowledge_sources`

`{ }`

Every source with status, page count, and last fetch time.

Returns: Sources.

#### `add_knowledge_source`

`{ kind: "site" | "page" | "text", url?: string, body?: string, title?: string }`

Crawl a whole site (same domain, robots.txt honoured, up to the plan allowance), fetch one page, or store pasted text. Sites and pages index in the background.

| Parameter | Type | Required |
| --- | --- | --- |
| kind | `"site" | "page" | "text"` | yes |
| url | `string` | no |
| body | `string` | no |
| title | `string` | no |

Returns: The source, with status.

#### `refresh_knowledge_source`

`{ source_id: string }`

Fetch a site or page again now. Sources are refreshed weekly on their own.

| Parameter | Type | Required |
| --- | --- | --- |
| source_id | `string` | yes |

Returns: { id, status }

#### `delete_knowledge_source`

`{ source_id: string }`

Removes a source and every page indexed from it.

| Parameter | Type | Required |
| --- | --- | --- |
| source_id | `string` | yes |

Returns: { deleted: true }

### Disposable inboxes

Throwaway addresses for signup and verification flows. They live on a domain kept apart from every sending domain, and they can only receive, so nothing here can put mail on the wire. /docs/verification-codes is the guide.

#### `create_temp_address`

`{ ttl_seconds?: number, label?: string }`

Creates a disposable address that receives mail for a short time and is then deleted along with everything it received. For signup and verification flows: register somewhere, catch the confirmation, read the code. Receive-only, and on a domain kept separate from your sending domains.

| Parameter | Type | Required |
| --- | --- | --- |
| ttl_seconds | `number` | no |
| label | `string` | no |

Returns: { address, expires_at, receive_only: true, remaining_slots }

#### `wait_for_message`

`{ address: string, timeout_seconds?: number }`

Blocks until mail arrives at one of your disposable addresses. Unlike wait_for_reply this is not tied to a thread, because a disposable inbox has nothing to reply to. Mail that arrived before the call is returned immediately, so triggering the email first is safe.

| Parameter | Type | Required |
| --- | --- | --- |
| address | `string` | yes |
| timeout_seconds | `number` | no |

Returns: The message, or { timed_out: true }.

#### `list_temp_addresses`

`{ }`

Your disposable addresses that are still alive, soonest to expire first.

Returns: Addresses with their labels and expiry.

#### `release_temp_address`

`{ address: string }`

Deletes a disposable address and its mail before expiry, freeing a slot against your concurrent limit.

| Parameter | Type | Required |
| --- | --- | --- |
| address | `string` | yes |

Returns: { released: true }

### Vault

#### `list_secrets`

`{ }`

Names, types, and tags of the secrets granted to you. No values, so finding the right secret is cheaper than opening one.

Returns: Secret metadata.

#### `get_secret`

`{ secret_id: string }`

Opens a granted secret and returns its value, so an agent can sign in or call an API without a human pasting the credential into the conversation. No key is passed: values are encrypted at rest and decrypted server-side for the agents that were granted them.

| Parameter | Type | Required |
| --- | --- | --- |
| secret_id | `string` | yes |

Returns: { name, type, secret }

#### `get_totp_code`

`{ secret_id: string }`

The current RFC 6238 code for a login secret that carries a TOTP seed, so you can clear a two-factor prompt without a human relaying codes.

| Parameter | Type | Required |
| --- | --- | --- |
| secret_id | `string` | yes |

Returns: { code, expiresInSeconds }

## REST API

Everything the MCP server does is also available over plain HTTPS, for stacks without MCP support. The base is https://mcpmailer.com and every endpoint takes an Authorization: Bearer mmk_... header. Bodies and responses are JSON unless noted.

### Messages

- `POST /v1/messages` Send. Same body as send_email.
- `GET /v1/messages` ?unread_only=true&thread_id=&include_archived=&limit=
- `GET /v1/messages/:id` Full message, and marks it read.
- `POST /v1/messages/:id/reply-all` { body }
- `POST /v1/messages/:id/forward` { to, body?, mode? }
- `POST /v1/messages/:id/archive` { archived?, unread? }
- `GET /v1/messages/:id/attachments/:filename` One attachment.
- `GET /v1/threads` Conversations, newest first.
- `GET /v1/threads/:id` Every message in one conversation.
- `GET /v1/search` ?q= ranked full-text search.

### Identity

- `GET /v1/identity` Handle, filter mode, rules.
- `PATCH /v1/identity` { handle?, display_name?, description?, filter_mode? }
- `GET /v1/identity/rules` Filter rules.
- `POST /v1/identity/rules` { match, value, action }
- `DELETE /v1/identity/rules/:id` Removes a rule.

### Contacts

- `GET /v1/contacts` ?q= search, ?lookup= reverse lookup. Accept: text/vcard exports all.
- `POST /v1/contacts` One contact, or { vcard } to bulk import.
- `GET /v1/contacts/duplicates` Pairs that look like the same person.
- `POST /v1/contacts/duplicates` { survivor_id, loser_id } merges them.
- `GET /v1/contacts/:id` Full contact. Accept: text/vcard returns a vCard.
- `PATCH /v1/contacts/:id` Merge patch.
- `POST /v1/contacts/:id` { fact } remembers something about them.
- `DELETE /v1/contacts/:id` Deletes the contact.
- `POST /v1/contacts/import` { csv, list_name? | list_id? } pasted CSV or address lines become contacts, optionally all on one list. Or send the text as text/csv with ?list_name=.
- `GET /v1/contacts/lists` Every list with its member count.
- `POST /v1/contacts/lists` { name, description? } creates a list.
- `GET /v1/contacts/lists/:id` The list and a page of members. ?limit= ?offset=, page until next_offset is null.
- `PATCH /v1/contacts/lists/:id` { name?, description? }
- `DELETE /v1/contacts/lists/:id` Deletes the list, keeps the contacts.
- `POST /v1/contacts/lists/:id/members` { contact_ids } adds contacts to the list.
- `DELETE /v1/contacts/lists/:id/members` { contact_ids } removes them from the list.

### Notes, knowledge, secrets, and signup

- `GET /v1/notes` ?q= to search.
- `POST /v1/notes` { title?, body }
- `GET /v1/notes/:id` PATCH merge patches it, DELETE removes it.
- `GET /v1/knowledge` The summary; ?q= returns ranked passages instead.
- `GET /v1/knowledge/pages/:id` One indexed page in full.
- `GET /v1/knowledge/sources` Every source with its status.
- `POST /v1/knowledge/sources` { kind: site | page, url } or { kind: text, body }. Sites and pages index in the background.
- `GET /v1/knowledge/sources/:id` POST fetches it again, DELETE removes it and its pages.
- `GET /v1/secrets` Metadata for granted secrets. No values.
- `GET /v1/secrets/:id` Opens a granted secret and returns its value. No key to pass.
- `GET /v1/mailboxes` Every inbox here, with the plan allowance.
- `POST /v1/mailboxes` { handle, domain_id? } creates one.
- `GET /v1/domains` Domains, with records a pending one still needs.
- `POST /v1/domains` { domain } returns the DNS records to publish.
- `POST /v1/domains/{id}/verify` Checks the records now and reports whether the domain can send yet.
- `DELETE /v1/domains?id=` Removes a custom domain.
- `GET /v1/webhooks` Endpoints. Secrets are not listed.
- `POST /v1/webhooks` { url, events, mailbox_id?, headers?, template? } returns the secret once.
- `PATCH /v1/webhooks?id=` Changes an endpoint in place. rotate_secret returns a new secret once.
- `DELETE /v1/webhooks?id=` Removes an endpoint.
- `GET /v1/webhooks/deliveries` What each attempt got back. webhook_id, limit, and before narrow it.
- `POST /v1/webhooks/deliveries` { webhook_id } sends a test; { delivery_id } replays a stored one.
- `POST /v1/signup` { handle, email? } provisions a workspace and a key.

The full description lives at /openapi.json (OpenAPI 3.1), which is enough to generate a client.

## Errors

There are two kinds of failure here, and they are shaped differently on purpose.

A refused send is a result. It comes back as HTTP 422 with a body of { "status": "rejected", "reason": ... } and it means the request was understood and declined for a reason an agent can act on. The SDKs do not throw on these: check result.status. Some carry retryAfter, an ISO timestamp saying when the thing that blocked you resets.

A held send is neither. If the agent is set to have a person read its mail first, the result is { "status": "held", "pendingId": ... } on a 202, meaning the request was accepted and is queued for review. It is not an error and must not be retried. See /docs/sending for how to find out what was decided.

Everything else is an error. It comes back with a 4xx or 5xx status and a body of { "error": ..., "hint": ... }, where hint is written to be read by whoever is debugging. The SDKs throw McpmailerError for these, carrying the status, the code, and the hint.

```json
// 422: understood, declined. Not an exception in any SDK.
{ "status": "rejected", "reason": "monthly_send_quota_exhausted", "retryAfter": "2026-08-01T00:00:00Z" }

// 4xx / 5xx: an error. McpmailerError in the SDKs.
{ "error": "secret_not_found_or_not_granted", "hint": "No secret with that id is granted to this agent." }
```

### Send rejections

Returned as reason on a 422 from POST /v1/messages, and from send in every SDK. Two of them embed a number, so match on the prefix rather than the whole string.

| reason | What happened | What to do |
| --- | --- | --- |
| sending_locked_verify_email | The workspace email address has never been verified, so nothing can go out. | Verify the address from the dashboard. Nothing sends until you do. |
| workspace_paused | The workspace is paused. | An owner has to resume it. An agent cannot clear this itself. |
| mailbox_inactive | The identity exists but its mailbox is not active. | Check the agent page. Usually a mailbox that was suspended. |
| mailbox_receive_only | This mailbox can accept mail but never send it. Disposable inboxes are always receive-only. | Send from a real identity instead. |
| mailbox_expired | A temporary mailbox is past its expiry. | Create a new one. |
| mailbox_not_found | No mailbox backs this key. | Almost always a key belonging to a deleted agent. |
| domain_not_verified | The custom domain this address sits on has not finished verifying. | Finish the DNS records. See /docs/domains. |
| too_many_recipients_max_5 | More than 5 recipients across to and cc. | Split the send. The number in the code is the limit. |
| reply_target_not_found | reply_to_message_id names a message that does not exist. | Look the message up first, or send it as a new message. |
| recipient_suppressed | One or more recipients previously bounced or complained. The offending addresses follow the colon. | Drop those addresses. Sending to them again is what gets a domain blocked. |
| daily_send_quota_exhausted | The daily allowance is gone. Free plans only. | Retry after retryAfter, or upgrade. |
| monthly_send_quota_exhausted | The monthly allowance is gone and the plan does not meter overage. | Retry after retryAfter, or upgrade. |
| monthly_spend_cap_reached | The workspace hit the spend ceiling its owner set. | Only an owner can raise it. Do not retry on a timer. |
| velocity_spike_detected | Cold sends jumped far above this mailbox's normal rate. | Stop and look at what the agent is doing. Recorded on the agent page. |
| velocity_spike_cooldown | A velocity trip already fired and the cooldown has not passed. | Wait out retryAfter, after you have found out what tripped it. |
| duplicate_content_burst_detected | The same body is going to many recipients in a short window. | Vary the message, or use fewer, better-targeted sends. |
| attachments_too_large_max_10485760_bytes | Attachments exceed 10 MB in total. | Send a link instead. The number in the code is the limit in bytes. |

The four velocity and duplicate reasons are tripwires against a runaway agent, not quotas. Hitting one means something is sending far more than usual, so the useful response is to stop and look rather than to retry on a timer. Velocity trips are recorded on the agent page.

### Rate limiting

A 429 with { "error": "rate_limited" } applies to the whole API, REST and MCP alike, at 300 requests per minute per key. It carries retry-after in seconds, which is a different thing from the retryAfter timestamp on a send rejection. Wait the stated interval rather than retrying immediately.

### Authentication and routing

These decide whether a call happens at all, so they arrive before any tool runs.

| error | Status | What it means |
| --- | --- | --- |
| unauthorized | 401 | No Authorization header, or one that is not a Bearer token. |
| invalid_key | 401 | The mmk_ key is unknown, revoked, or expired. |
| invalid_token | 401 | The OAuth access token is malformed or expired. |
| insufficient_scope | tool result | The token lacks the scope this tool needs. The response names it. |
| unknown_agent | 403 | X-MCPmailer-Agent names a handle that is not in this workspace. |
| ambiguous | 403 | An OAuth token covers several agents and none was named. Set X-MCPmailer-Agent: we refuse rather than guess which address mail leaves from. |
| no_agent | 403 | The account has no agent to act as yet. |
| no_workspace | 403 | The account has no workspace. |
| rate_limited | 429 | 300 requests per minute per key. Carries retry-after in seconds. |

### Not found

A 404 means the thing does not exist, or exists and this key is not allowed to see it. The two are deliberately not distinguished: telling an agent that a secret exists but is not granted to it would leak the workspace it cannot enumerate.

| error | Where | What it means |
| --- | --- | --- |
| message_not_found | messages | No message with that id in this mailbox. |
| thread_not_found | threads | No thread with that id in this mailbox. |
| attachment_not_found | attachments | That message carries no such filename. The response lists the ones it does have. |
| attachment_bytes_missing | attachments | The attachment is known but its bytes are gone. |
| raw_message_unavailable | forward | The original MIME is no longer stored, so it cannot be forwarded inline. |
| contact_not_found | contacts | No contact with that id in this workspace. |
| note_not_found_or_not_granted | notes | No such note, or this agent has not been granted it. |
| secret_not_found_or_not_granted | vault | No such secret, or this agent has not been granted it. |
| no_totp_on_secret | vault | The secret has no TOTP seed, so no code can be generated. |
| no_identity | identity, notes, vault | The key resolves to no identity. |
| rule_not_found | filtering | No filter rule with that id. |
| unknown_domain | mailboxes | The domain named for a new mailbox is not one of yours. |
| not_found | domains, webhooks | No domain or webhook endpoint with that id. |
| temp_address_not_found | disposable inboxes | No disposable address with that id. |
| temp_address_not_found_or_expired | disposable inboxes | The address is gone or past its expiry. |

### Limits and conflicts

| error | Status | What it means |
| --- | --- | --- |
| custom_domain_limit_reached | 402 | The plan's custom domain allowance is used up. The response carries included and used. |
| webhook_limit_reached | 402 | The plan's webhook endpoint allowance is used up. |
| temp_address_limit_reached | tool result | Too many live disposable addresses. Let some expire. |
| handle_taken | 409 | Handles are unique across all of MCPmailer, not just your workspace. |
| domain_taken | 409 | That domain is already claimed by another workspace. |
| domain_in_use | 400 | The domain still has mailboxes on it, so it cannot be removed. |
| too_many_cards | 400 | A vCard import over 1,000 entries. Split the file. |
| no_recipients | 422 | reply_all had nobody to reply to once you were excluded. |
| vault_unavailable | 503 | The key store did not answer. Retry. |
| secret_unreadable | 500 | The secret is stored but did not decrypt. Retrying will not help. |

### Bad requests

All 400s, all meaning the request was malformed rather than refused. invalid_json is a body that did not parse; invalid_request is a body that parsed but failed validation. The rest name the field they are about: body_required, name_required, fact_required, handle_required, events_required, id_required, https_url_required, invalid_handle, invalid_email, invalid_domain, invalid_value, invalid_filter_mode, not_your_domain, nothing_to_update, no_valid_cards, too_many_cards. Webhook registration adds a few of its own: private_url_not_allowed for an endpoint on a private or loopback address, credentials_in_url for a token in the URL rather than a header, and reserved_header_name, invalid_header_name, invalid_header_value, too_many_headers, invalid_template, template_too_large for custom headers and body templates.

### Vault failures

vault_unavailable is a 503: the key store did not answer and the secret was not read. It is worth retrying. secret_unreadable is a 500 and is not: the ciphertext is there but did not open, which means something is wrong on our side and retrying will not fix it.

### Over MCP

The transport-level refusals above still arrive as HTTP status codes. Everything a tool itself refuses comes back as an ordinary tool result whose JSON carries an error field, because a tool that failed still returned. insufficient_scope means the token or key was not granted the scope this tool needs, and the required scope is named in the response. The REST API returns the same error as a 403 when a restricted key reaches past its grant.

Retry 429, 502, 503, and 504 with backoff. Do not retry a 4xx: nothing about the request will have changed. Do not retry a send rejection except where the table above says to, and then only after retryAfter.

## Connecting over OAuth

An API key is the fastest way in, but hosted clients that cannot store one (claude.ai connectors and similar) can authorize as you instead. We are an OAuth 2.1 authorization server: clients discover us at /.well-known/oauth-authorization-server, register themselves, and send you here to approve what they are asking for.

```bash
# what a client discovers
GET https://mcpmailer.com/.well-known/oauth-authorization-server
GET https://connect.mcpmailer.com/.well-known/oauth-protected-resource

# and then, with a token
curl https://connect.mcpmailer.com/mcp \
  -H "Authorization: Bearer <access token>" \
  -H "X-MCPmailer-Agent: sales"
```

### Scopes

Scopes are deliberately coarse: mail:read, mail:send, contacts, notes, and vault. Vault is the one to be careful with, since it reads credentials, so grant it only to agents that need one. Ask for offline_access if the agent runs unattended and needs to refresh. PKCE is required, and tokens are audienced to the MCP endpoint, so a token minted for another service is refused here.

### Choosing an agent

A token identifies you, not one of your agents. If you have a single agent the connection uses it. If you have several, name one with an X-MCPmailer-Agent header carrying its handle, and we refuse rather than guess: picking for you would mean mail leaving the wrong address.

Revoke a connection at any time from Connections in the dashboard; the app is cut off immediately and has to ask again.

## Webhooks

Nothing here is required. Your agent already gets mail without it: webhooks exist so something else can be told the moment mail arrives, whether that is a service you wrote or an assistant that is already running. If you connected an assistant over MCP and only want it to wait for one reply, use wait_for_reply instead and skip this page.

Register an https endpoint under Webhooks in the dashboard, or with create_webhook, to receive signed POSTs. A failed delivery is retried five times, waiting about 30 seconds, then 2, 8, and 30 minutes, so a receiver that is restarting or briefly down does not lose the event. If your endpoint answers 429 or 503 with a Retry-After, we wait exactly that long instead.

A new endpoint is subscribed to message.received and nothing else. If you want bounces or complaints, ask for them when you register: an endpoint that never fires is almost always one that was never subscribed.

```http
POST /your-endpoint HTTP/1.1
content-type: application/json
x-mcpmailer-event: message.received
x-mcpmailer-delivery-id: whd_9c1f4a2b7e0d4c58a1b6
x-request-id: whd_9c1f4a2b7e0d4c58a1b6
x-mcpmailer-signature: t=1753440000,v1=9f2c...e1

{
  "id": "whd_9c1f4a2b7e0d4c58a1b6",
  "event": "message.received",
  "timestamp": "2026-07-28T09:14:02.117Z",
  "data": { "message_id": "msg_01J9X8Q2K7", "from": "ada@example.com" }
}
```

Every delivery has the same envelope: id, event, timestamp as an ISO 8601 string, and data. The event name and the id are repeated in x-mcpmailer-event and x-mcpmailer-delivery-id headers, so a router can dispatch and dedup without parsing the body first. The id also goes out as x-request-id, which is the name gateways and proxies already dedup and correlate on, so a receiver that understands it gets that for free.

### Handling repeats

Delivery is at-least-once. An endpoint that takes longer than ten seconds is treated as failed and tried again, even though it usually finished the work, so the same event can arrive more than once. Every copy carries the same id, and that is the value to key on: record the ids you have handled, and return 200 without doing anything when one comes back.

```ts
// At-least-once: the same event can arrive more than once, and every copy
// carries the same id. Record it, and let a repeat fall straight through.
if (await seen.has(body.id)) return new Response('ok');
await seen.add(body.id, { ttlSeconds: 86_400 });
await handle(body);
```

A replay sent from the dashboard keeps the original id too, so a receiver that dedups correctly will ignore it. Rotate your stored ids out after a day or so; nothing is retried for longer than that.

### Which agent, and which events

An endpoint follows the whole workspace by default, which is what you want when one service handles everything. Point it at a single agent instead, in the dashboard or with mailbox_id, when a workspace runs several and each has its own handler. A scoped endpoint hears only that agent, and bounces and complaints follow the agent that sent the message.

### Endpoints that expect their own shape

Some receivers were built before they ever heard of us and will not accept our envelope. Two settings cover them. Custom headers carry whatever auth the receiver requires, one per line in the dashboard or as a headers object over the API. A body template replaces the envelope with the receiver's own JSON, filling {{event}}, {{timestamp}}, {{id}}, and any {{data.field}} placeholder. The signature still covers whatever body is actually sent.

Header values are write-only over the API: a read lists the names you configured and never the values, because one of them is usually the receiver's own token. The dashboard does show them, to the same signed-in admins who can already reveal the signing secret on that page.

### When an endpoint keeps failing

Consecutive failures are counted on the endpoint and shown next to it. After twenty in a row we stop trying and mark it turned off, rather than keep POSTing at a URL nobody is listening on, and the workspace owner is emailed so this is not something you find out by chance. A successful test delivery clears the count, and resuming the endpoint starts it over. Events that happen while it is off are held for seven days and delivered once you turn it back on, within a few minutes of doing so, so fixing a receiver inside that window loses nothing. Pausing an endpoint yourself holds nothing: that is you saying you do not want these events, not a receiver we expect back.

Answering 410 Gone turns the endpoint off immediately instead. Use it when a URL is retired for good: it saves both sides several hours of attempts against a host that will never answer.

### Finding out why

Every attempt is logged with the status code, the response body, and how long it took, and kept for thirty days. The log is on the endpoint in the dashboard and also on the API, because the thing that owns an endpoint is usually a program and it should be able to look without a person. A status code means the receiver answered and something about the request was wrong; an error with no status means the request never got there at all, which is DNS, TLS, or a timeout.

```bash
# What each attempt got back, newest first.
mcpmailer webhooks:deliveries <endpoint-id>

# Send a sample event now and print the response.
mcpmailer webhooks:test <endpoint-id>

# Send a stored payload again, under its original id.
mcpmailer webhooks:replay <delivery-id>
```

A test sends a sample message.received event now and answers with exactly what came back, nothing queued and nothing retried. A replay sends a stored payload again, byte for byte and under its original id, which is what you want once a broken receiver is fixed. Both are list_webhook_deliveries, test_webhook, and replay_webhook_delivery over MCP.

Endpoints must be public https. A URL that resolves to a private or loopback address is refused when you register it and again before each attempt, so a laptop needs a tunnel rather than a LAN address.

### Events

| event | Fires when |
| --- | --- |
| message.received | Mail arrived and was stored. Subscribed by default. |
| message.filtered | Inbound mail was dropped by the identity filter, before storage. |
| message.sent | A message you sent was accepted for delivery. |
| message.delivered | The receiving server accepted a message you sent. |
| message.bounced | A recipient rejected a message. The address is now suppressed. |
| message.complained | A recipient marked a message as spam. The address is now suppressed. |
| approval.approved | A person released a message your agent was holding, and it went. Carries pending_id and message_id. |
| approval.rejected | A person discarded it. Carries decision_note, their reason, which is worth reading before rewriting. |
| approval.expired | Nobody acted on it within a week, so it will not be sent. |
| domain.verified | A domain finished verification and can send. Fires whether a person clicked verify, an agent called verify_domain, or the nightly re-check found the records. |
| workspace.throttled | Bounce or complaint rates crossed the throttle line. Cold sends are slowed; replies still go. Clears on its own after a clean day. |
| workspace.paused | Rates crossed the pause line. Every send is refused until a person has looked. Carries the reason. |
| workspace.resumed | A throttle cleared after a clean day of sending. |

reason on a filtered delivery is not_whitelisted when an allow-list identity did not recognise the sender, and blocked_sender when a rule blocked them outright. spam_verdict on a received delivery is the receiving verdict, PASS or FAIL, or UNKNOWN when none was returned.

### message.received

The one most agents want. It fires after the message is stored, so the id in it can be passed straight to read_message or reply_all.

```json
{
  "id": "whd_9c1f4a2b7e0d4c58a1b6",
  "event": "message.received",
  "timestamp": "2026-07-28T09:14:02.117Z",
  "data": {
    "message_id": "msg_01J9X8Q2K7",
    "thread_id": "thr_01J9X8Q2K7",
    "mailbox": "scout@agents.yourcompany.com",
    "from": "ada@example.com",
    "subject": "Re: Shipping update",
    "snippet": "That works for us, Thursday is fine.",
    "spam_verdict": "PASS"
  }
}
```

message_id can be absent if storage failed but delivery went ahead. Treat it as optional and fall back to thread_id.

### message.filtered

Fires when inbound mail is dropped by the identity filter. It happens before storage, so there is no message id and nothing to fetch later. This is the only way to see mail that was rejected, which makes it the event to subscribe to when a whitelisted agent seems to be receiving nothing.

```json
{
  "id": "whd_4b8e2d6a0f3c47915ade",
  "event": "message.filtered",
  "timestamp": "2026-07-28T09:14:02.117Z",
  "data": {
    "mailbox": "scout@agents.yourcompany.com",
    "from": "stranger@example.net",
    "subject": "Quick question",
    "reason": "not_whitelisted",
    "filter_mode": "whitelist"
  }
}
```

### message.sent and message.delivered

The other half of the picture for an agent that follows up on its own mail. message.sent fires when a message is accepted for delivery; message.delivered fires when the receiving server accepts it, which is as close to "it landed" as SMTP gets. Subscribe to these when the decision to chase depends on whether the first message arrived, because bounces only ever tell you about the failures.

```json
{
  "id": "whd_4b8e1c0a9f7d2e63b5a1",
  "event": "message.delivered",
  "timestamp": "2026-07-28T09:15:41.882Z",
  "data": {
    "message_id": "msg_01J9X8Q2K7",
    "thread_id": "thr_01J9X8Q2K7",
    "to": ["ada@example.com"],
    "subject": "Your invoice"
  }
}
```

Delivered is not read. It means the recipient's server accepted the message, not that a person saw it, and a message can be accepted and then filed as spam without another event.

### message.bounced and message.complained

Both carry the same shape, and both mean the address is now suppressed: later sends to it are refused with recipient_suppressed. bounce_type is present only on a bounce.

```json
{
  "id": "whd_71ad3f9c2b6e480d95cc",
  "event": "message.bounced",
  "timestamp": "2026-07-28T09:14:02.117Z",
  "data": {
    "message_id": "msg_01J9X8Q2K7",
    "to": ["ada@example.com"],
    "subject": "Shipping update",
    "bounce_type": "Permanent"
  }
}
```

### Verifying a delivery

The x-mcpmailer-signature header carries a timestamp and an HMAC-SHA256 of "timestamp.body", keyed with the endpoint secret. The SDK ships the check, and using it is the recommendation rather than a formality: it returns the parsed event when the delivery is authentic and null when it is not, so the handler is two lines.

```ts
import { verifyWebhook } from '@mcpmailer/sdk';

export async function POST(request: Request) {
  // The raw bytes, not JSON. Parsing and re-serialising changes them, and the
  // signature covers bytes.
  const raw = await request.text();
  const event = await verifyWebhook(raw, request.headers, process.env.MCPMAILER_WEBHOOK_SECRET);
  if (!event) return new Response('bad signature', { status: 401 });

  // Authentic, and recent. Safe to act on.
  await handle(event);
  return new Response('ok');
}
```

Two things have to hold, and skipping either is where hand-written checks go wrong. The HMAC has to match, compared against the raw body before any JSON parsing and in constant time. The timestamp has to be within five minutes of now, because without that a delivery captured once stays valid forever and can be replayed at any later point. If you are not on the TypeScript SDK, this is the whole check in any language.

```ts
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(raw: string, header: string, secret: string, toleranceSec = 300) {
  const parts = Object.fromEntries(
    header.split(',').map((p) => [p.slice(0, p.indexOf('=')), p.slice(p.indexOf('=') + 1)])
  );
  const t = Number(parts.t);
  if (!Number.isFinite(t)) return false;

  // Without this the signature is valid forever and a captured delivery can be
  // replayed at any later point.
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;

  // Every v1 in the header, because during a secret rotation there is one per
  // valid secret and any of them matching means the delivery is ours.
  const expected = createHmac('sha256', secret).update(`${t}.${raw}`).digest('hex');
  return header
    .split(',')
    .filter((p) => p.startsWith('v1='))
    .some((p) => {
      const v1 = p.slice(3);
      // Length first: timingSafeEqual throws on a mismatch, which would turn a
      // malformed header into a 500 instead of a 401.
      return v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
    });
}
```

Check every v1 in the header, not just the first. During a secret rotation the header carries one signature per valid secret, and a receiver that only reads the first will refuse real deliveries for whichever secret it is not holding.

### Receivers that verify the generic scheme

The same signature also goes out under two generic names, on every delivery and with nothing to turn on: x-webhook-signature-v2 carries the bare hex digest, and x-webhook-timestamp carries the unix seconds it was made at. It is the identical construction, so a receiver that already knows how to check an HMAC of "timestamp.body" accepts our deliveries once you give it the signing secret, with no code written for us at all. Hermes is the one people hit first, and its recommended mode reads exactly these two headers.

If you are writing the receiver yourself, prefer our header: it is the one that carries both signatures during a rotation.

### Rotating the secret

Rotating gives you a new secret once, and keeps the old one verifying for 24 hours. Deliveries in that window are signed with both, so you can rotate first and deploy the receiver afterwards without a gap where correct signature checks refuse real events. Update the receiver before the window closes; after it, only the new secret verifies. The generic x-webhook-signature-v2 header holds one digest rather than two, so a receiver verifying that one has no window at all: it carries the new secret from the moment you rotate.

### Waking an assistant

A long-running assistant is the case webhooks are best at, and pointing one at OpenClaw, n8n, or Zapier is a page of its own: /docs/waking has the templates, the headers each of them wants, and the three ways of finding out mail arrived compared.

## SDKs and agent access

Every client here speaks the same v1 API with the same key, so nothing you learn on one is wasted on another. An agent that lives in a TypeScript worker and a script that runs from cron can share a mailbox without knowing about each other.

- **TypeScript**: npm install @mcpmailer/sdk. Runs on Node, Bun, Deno, and Cloudflare Workers.
- **Python**: pip install mcpmailer. Standard library only, so your runtime keeps its dependency tree.
- **CLI**: Mail, contacts, notes, and the vault from a shell, a Makefile, or a cron job.
- **Other languages**: Go, Ruby, PHP, Java, C#, anything with an HTTP client. Nothing to install.

### Authentication

One header, everywhere: Authorization: Bearer mmk_live_.... The key is scoped to a single identity, so it can only send from the address it owns and only read the mail addressed to it. A key can also be issued with less than everything: untick capabilities on the key form and requests outside them come back 403 insufficient_scope. Both SDKs and the CLI read MCPMAILER_API_KEY from the environment when you do not pass a key yourself.

### What every client agrees on

A refused send is a result, not an error. You get { status: "rejected", reason } back with a 422, because a quota that ran out or a suppressed recipient is something an agent can reason about and retry differently. Exceptions are kept for transport, authorization, and rate limiting, where there is nothing to decide.

Timestamps are ISO 8601 in UTC. Ids are prefixed and opaque: msg_ for messages, thr_ for threads, sec_ for vault secrets. Do not parse them.

### For agents reading this

A machine-readable overview lives at /llms.txt, the full API description at /openapi.json, and these pages as markdown at /docs.md. An agent with no human account can provision itself with POST /v1/signup and hand the returned claim URL to a person later.

## TypeScript

The TypeScript SDK is a thin, fully typed wrapper over the v1 API with no runtime dependencies. It uses the global fetch, so it runs unchanged on Node 18 and up, Bun, Deno, and Cloudflare Workers.

```bash
npm install @mcpmailer/sdk
```

### The client

One class, one method per call, no local state. Pass your own fetch if you need to route requests through a proxy or a test double.

```ts
import { Mcpmailer } from '@mcpmailer/sdk';

// Reads MCPMAILER_API_KEY when you pass nothing.
const mm = new Mcpmailer();

// Or be explicit, and point at another deployment if you need to.
const staging = new Mcpmailer({
  apiKey: process.env.STAGING_KEY,
  baseUrl: 'https://staging.mcpmailer.com'
});
```

### Sending

Attachments take raw bytes or a base64 string; the SDK encodes them for you. Set style to plain, flat, or card to override how the message looks, or leave it out and let the mailbox decide.

```ts
const result = await mm.send({
  to: ['jane@acme.com'],
  subject: 'Your quote',
  body: 'Attached, as promised.',
  attachments: [
    { filename: 'quote.pdf', content: await Bun.file('quote.pdf').bytes() }
  ],
  trackOpens: true
});

if (result.status === 'rejected') {
  // A refused send is a result, not a throw: reason is something to act on.
  console.log(result.reason); // daily_send_quota_exhausted, recipient_suppressed, ...
}
```

### Reading and answering

The methods below cover the loop most agents actually run: read what came in, find out who wrote, answer, record what you learned, and get it out of the inbox.

```ts
const inbox = await mm.listMessages({ unreadOnly: true, limit: 20 });

for (const message of inbox) {
  const [who] = await mm.lookupContact(message.from);

  await mm.replyAll(message.id, 'Thanks, looking into it now.');

  if (who) {
    await mm.rememberAboutContact(who.id, 'Asked about SSO pricing', message.id);
  }
  await mm.archiveMessage(message.id);
}
```

Also on the client: getMessage, getThread, search, getAttachment, markUnread, forward, listNotes and createNote, listContacts and createContact, listMailboxes and createMailbox, listDomains, listWebhooks, createWebhook and updateWebhook.

### The vault

Secrets this agent has been granted, read with the same key as everything else. Values are encrypted at rest and opened server-side for the agents that were granted them, so a credential can be used without ever passing through a prompt.

```ts
const secrets = await mm.listSecrets();
const stripe = await mm.getSecret(secrets[0].id);
// The value arrives ready to use, without ever passing through a prompt.

const { code, expiresInSeconds } = await mm.getTotpCode(secrets[0].id);
```

### Errors

McpmailerError carries the HTTP status, a stable code, and a hint meant to be read. A rejected send does not throw: check result.status instead.

```ts
import { McpmailerError } from '@mcpmailer/sdk';

try {
  await mm.send({ to: ['jane@acme.com'], subject: 'Hi', body: 'Hello' });
} catch (error) {
  if (error instanceof McpmailerError) {
    console.log(error.status, error.code, error.hint);
  }
}
```

There is no waitForReply in the TypeScript SDK. Over MCP the server long-polls for you; over REST, subscribe to the message.received webhook rather than polling a thread. See /docs/webhooks.

## Python

The Python client is written against the standard library alone, so an agent runtime does not inherit a dependency tree just to send an email. It needs Python 3.9 or newer. The optional vault extra pulls in cryptography for AES-GCM.

```bash
pip install mcpmailer          # the client, standard library only
pip install 'mcpmailer[vault]' # adds AES-GCM for reading vault secrets
```

### The client

Methods are synchronous and return plain dicts, which is what an LLM tool layer usually wants to hand back anyway.

```py
from mcpmailer import Mcpmailer

mm = Mcpmailer()  # reads MCPMAILER_API_KEY

staging = Mcpmailer(api_key="mmk_live_...", base_url="https://staging.mcpmailer.com")
```

### Sending

Attachments are (filename, bytes) tuples, or dicts if you want to set the content type yourself.

```py
result = mm.send(
    to=["jane@acme.com"],
    subject="Your quote",
    body="Attached, as promised.",
    attachments=[("quote.pdf", open("quote.pdf", "rb").read())],
    track_opens=True,
)

if result["status"] == "rejected":
    print(result["reason"])  # daily_send_quota_exhausted, recipient_suppressed, ...
```

### Reading and answering

```py
for message in mm.list_messages(unread_only=True):
    who = mm.lookup_contact(message["from"])

    mm.reply_all(message["id"], "Thanks, looking into it now.")

    if who:
        mm.remember_about_contact(who[0]["id"], "Asked about SSO pricing", message["id"])
    mm.archive(message["id"])
```

Also on the client: get_message, get_thread, search, get_attachment, mark_unread, forward, list_notes and create_note, list_contacts and create_contact, import_vcards and export_vcards, import_contacts_csv and the contact list calls, list_mailboxes and create_mailbox, list_domains, list_webhooks, create_webhook and update_webhook, list_secrets, get_secret, get_totp_code.

### Waiting for a reply

wait_for_reply polls the thread until something inbound lands or the deadline passes, and returns None if nobody wrote back. Give it a generous timeout and treat None as a nudge to try later, not as a failure.

```py
import time

sent = mm.send(to=["jane@acme.com"], subject="Quick check", body="Does Thursday work?")
thread_id = mm.get_message(sent["messageId"])["thread_id"]

reply = mm.wait_for_reply(thread_id, timeout_seconds=900, poll_seconds=15)

if reply is None:
    print("no answer yet")  # nudge later, do not block the agent forever
```

### Errors

RateLimited carries retry_after in seconds and subclasses McpmailerError, so one except clause catches both if you do not care about the difference.

```py
from mcpmailer import McpmailerError, RateLimited

try:
    mm.send(to=["jane@acme.com"], subject="Hi", body="Hello")
except RateLimited as error:
    time.sleep(error.retry_after)
except McpmailerError as error:
    print(error.status, error.code, error.hint)
```

## Command line

The CLI is the same API with a shell in front of it. It is the fastest way to check what an agent actually sent, and it is useful inside cron jobs and Makefiles where a whole runtime would be overkill.

```bash
npx @mcpmailer/cli mail:list
# or install it: npm install -g @mcpmailer/cli

export MCPMAILER_API_KEY=mmk_live_...
```

### Everyday commands

Message bodies come from --body, from --file, or from stdin, so piping into the CLI works the way you would expect. Add --json to any command for machine-readable output.

```bash
mcpmailer mail:send --to jane@acme.com --subject "Your quote" --file quote.md
mcpmailer mail:list --unread --limit 10
mcpmailer mail:read msg_01J9X8Q2K7
mcpmailer mail:reply msg_01J9X8Q2K7 --all --body "On it."

echo "Follow up on Thursday" | mcpmailer notes:add --title "Acme"
mcpmailer secrets:totp sec_01J9X8Q2K7
mcpmailer domains:list --json
```

### The full command list

Mail: mail:send, mail:list, mail:read, mail:thread, mail:reply, mail:forward, mail:archive, mail:search, mail:pending, mail:scheduled. Contacts: contacts:list, contacts:lookup, contacts:import (vCard or CSV, --list puts them on a list), contacts:export. Lists: lists:list, lists:create, lists:get, lists:add, lists:remove, lists:delete. Notes: notes:list, notes:add. Vault: secrets:list, secrets:get, secrets:totp. Identity: identity:show, identity:block. Workspace: inbox:list, inbox:add, domains:list, domains:add, domains:remove, webhooks:list, webhooks:add, webhooks:update, webhooks:remove. Other: tunnel, signup.

Global flags apply everywhere: --api-key overrides the environment, --base-url points at another deployment, and --json prints raw JSON. Run mcpmailer with no arguments for the built-in help.

domains:list prints the DNS records a pending domain is still waiting on, which is usually the quickest way to find out why a custom domain has not verified.

## Other languages

There is nothing special about the SDKs. The API is JSON over HTTPS with a bearer token, so any language with an HTTP client is a first-class client. The examples below all do the same thing: send one message. Everything else in /docs/rest works the same way.

### Go

```go
package main

import (
	"bytes"
	"encoding/json"
	"net/http"
	"os"
)

func main() {
	body, _ := json.Marshal(map[string]any{
		"to":      []string{"jane@acme.com"},
		"subject": "Your quote",
		"body":    "Attached, as promised.",
	})

	req, _ := http.NewRequest("POST", "https://mcpmailer.com/v1/messages", bytes.NewReader(body))
	req.Header.Set("Authorization", "Bearer "+os.Getenv("MCPMAILER_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
}
```

### Ruby

```rb
require "net/http"
require "json"

uri = URI("https://mcpmailer.com/v1/messages")
res = Net::HTTP.post(uri, {
  to: ["jane@acme.com"],
  subject: "Your quote",
  body: "Attached, as promised."
}.to_json, {
  "Authorization" => "Bearer #{ENV['MCPMAILER_API_KEY']}",
  "Content-Type" => "application/json"
})

puts JSON.parse(res.body)["messageId"]
```

### PHP

```php
<?php
$res = file_get_contents('https://mcpmailer.com/v1/messages', false, stream_context_create([
  'http' => [
    'method' => 'POST',
    'header' => "Authorization: Bearer " . getenv('MCPMAILER_API_KEY') . "\r\n" .
                "Content-Type: application/json\r\n",
    'content' => json_encode([
      'to' => ['jane@acme.com'],
      'subject' => 'Your quote',
      'body' => 'Attached, as promised.',
    ]),
  ],
]));

echo json_decode($res, true)['messageId'];
```

### Anything else

Java, C#, Rust, Elixir, and the rest follow the same three rules: POST JSON, set the Authorization header, and read the status field on the response. /openapi.json is a complete OpenAPI 3.1 description, so a generated client is a reasonable option too.

```bash
curl https://mcpmailer.com/v1/messages \
  -H "Authorization: Bearer mmk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "to": ["ada@example.com"],
    "subject": "Shipping update",
    "body": "The parts landed this morning."
  }'
```

A 200 means the message went out. A 422 with a status field is a refusal you can act on, not a transport failure, so treat it as a result and read reason. A 429 carries retry-after in seconds.

```json
{
  "status": "sent",
  "messageId": "msg_01J9X8Q2K7"
}
```

Receiving mail does not need polling in any language: point a webhook at your service and verify the signature. See /docs/webhooks for the payload and the check.
