PosthasteDocsGet an API key

Getting started

MCP server

The Model Context Protocol is how an AI assistant is given tools it can call. An MCP server is a small program the assistant runs on your machine; it advertises a list of tools, and the model chooses which to call and with what arguments. @posthaste/mcp-server is ours. Point it at an API key and your agent can send mail, read back what happened to it, and check the sending reputation it is spending — without you writing an integration.

Wire it up

Add this to your client’s MCP configuration — claude_desktop_config.json for Claude Desktop, .mcp.json in a project for Claude Code, or the equivalent file for any other MCP client. Node 22 or newer is required.

{
  "mcpServers": {
    "posthaste": {
      "command": "npx",
      "args": ["-y", "@posthaste/mcp-server"],
      "env": {
        "POSTHASTE_API_KEY": "ph_live_…"
      }
    }
  }
}

Or, from a terminal with Claude Code installed:

claude mcp add posthaste \
  --env POSTHASTE_API_KEY=ph_live_… \
  -- npx -y @posthaste/mcp-server

The key goes in the environment, never in the arguments. Anything on a command line is visible in the process list to every other user on the machine, and it lands in shell history. No tool this server offers takes a credential as a parameter either — a tool argument is chosen by the model out of whatever is in its context, which makes it both a prompt-injection target and a guaranteed leak into the transcript.

On start it authenticates once, asks the API what the key may do, and builds the tool list from the answer. If the key is rejected — or cannot read its own scopes — it refuses to start and says which of the two happened, rather than presenting a menu of tools that will fail later.

Environment

VariableNotes
POSTHASTE_API_KEYRequired. A ph_live_ key. It must carry account:read — that is the endpoint the server uses to discover its own permissions. Getting a key →
POSTHASTE_API_URLOptional. Defaults to https://api.posthastemail.dev.
POSTHASTE_ALLOWED_RECIPIENTSOptional. Comma-separated addresses or bare domains. When set, a send to anything else is refused inside the MCP server, before the API is called. Unset means no local restriction.
POSTHASTE_READ_ONLYOptional. 1 removes every tool that can change anything, whatever the key’s scopes allow.
POSTHASTE_DUPLICATE_WINDOW_SECONDSOptional, default 300. How long an identical send counts as an accidental repeat. 0 disables the check.

The tools, and the scope each one needs

A tool whose scope the key does not hold is not offered at all. Mint the key with the scopes for the tools you actually want, and the agent sees exactly those — no more, and nothing that will refuse it halfway through a plan.

ToolWhat it doesScope
get_accountPlan, the key’s own scopes, and how much of today’s and this month’s sending allowance is left.account:read
list_domainsEvery sending domain and whether it is verified — which is to say, what may legally appear in from.domains:read
list_streamsThe message streams a send can name.streams:read
list_templatesStored templates and the variables each one declares.templates:read
preview_emailEvery check a send runs, and no send. Same arguments as send_email.emails:send
send_emailSend a message. Irreversible.emails:send
cancel_scheduled_emailCall back a scheduled message that has not yet been released to delivery.emails:send
get_messageOne message: its delivery status, whether that status is final, and the full hash-chained waybill.messages:read
list_messagesThe message log, filtered by status, stream, tag, metadata, recipient, free text or date.messages:read
get_message_statsDelivery, bounce and complaint counts and rates, next to the industry thresholds they are judged against.messages:read
list_suppressionsAddresses that will never be sent to, and why.suppressions:read
add_suppressionStop sending to an address.suppressions:write

The narrowest key that does anything useful — send a message, read what happened to it — is three scopes:

POSTHASTE_API_KEY   a key with: account:read, emails:send, messages:read

Safety

Sending mail is the one thing an agent cannot undo. There is no unsend, no recall and no delete: once a message is accepted it is delivered to a real mailbox seconds later. Everything below exists because a model that is merely usually right is not good enough when being wrong means a stranger receives mail from your domain.

A dry run that runs the real checks

preview_email takes exactly the arguments send_email takes — the same schema object, so the two cannot validate differently — and answers the same questions the accept path asks. Nothing leaves the building.

preview_email({
  from: "Acme <[email protected]>",
  to: "[email protected]",
  subject: "Your receipt",
  text: "Thanks for your order.",
})

→ No blocking problem found. This send looks acceptable.

  recipients (1): [email protected]

  checks:
    [SKIPPED] recipient_allowlist: No POSTHASTE_ALLOWED_RECIPIENTS configured…
    [PASS]    from_domain: acme.com is verified.
    [PASS]    suppressions: None of the 1 recipient(s) are on the suppression list.
    [PASS]    allowance: 996 send(s) left today of 1000, 99960 left this month.
    [SKIPPED] content: Inline content cannot be linted without sending it…

  NOTHING WAS SENT. To send it, call `send_email` with these same arguments.

A check reported as SKIPPED means it could not be run, usually because the key lacks the scope for it. It never means “fine”. A preview that quietly downgraded “I could not look” to “looks acceptable” would be worse than no preview, because it turns a missing permission into false confidence at exactly the moment confidence is expensive.

When the send names a template, the preview renders it through POST /v1/templates/:id/preview and reports the real pre-send content lint — so a template that would be refused is refused here first, for free. Inline bodies have no equivalent endpoint, and the preview says so rather than running a second, drifting copy of the lint.

An accidental repeat is refused, not delivered

The characteristic agent mistake is not a wild request. It is a correct one, made twice — the model does not see the result, decides it had better make sure, and calls again. Two copies of a receipt is the sort of thing people report as spam.

send_email({ from, to, subject: "Your receipt", text: "Thanks." })
→ Sent. Message id msg_AZLm3kQ8T2Sf9pXbNc7HrQ, status queued.

// The same call again, moments later:
send_email({ from, to, subject: "Your receipt", text: "Thanks." })
→ Refused: an identical message was already sent through this server a
  moment ago, as msg_AZLm3kQ8T2Sf9pXbNc7HrQ. Nothing was sent now.

  If you genuinely want a second copy delivered, call again with
  `allowDuplicate: true`.

The check is local, exact and bounded by a window you set. It is deliberately not implemented by defaulting idempotencyKey to a hash of the message: idempotency here has no expiry, so that would mean identical content could be sent to an address once and never again — refusing next month’s reminder six weeks later, silently. Every send does get a generated idempotency key so that a dropped connection cannot produce two deliveries; you can still supply your own.

Tools that are absent on purpose

There is no tool to remove a suppression, at any scope. The endpoint exists and the SDK exposes it; this server withholds it, because the two directions are not symmetrical. Adding an address can only reduce what is sent, and the worst case is somebody not receiving mail they wanted. Removing one re-enables mail to an address that got on the list by bouncing permanently, by being reported as spam, or by being a spam trap — and a model is in no position to judge whether the recipient has re-consented, since the only evidence it could have is somebody in the conversation saying so, which is precisely the shape of the attack. Removal stays a dashboard action for a person who can see the reason and be accountable for overriding it.

Minting and revoking API keys, changing who is on the account, and anything that spends money are absent for a different reason: they refuse a bearer token outright and are reachable only by a signed-in person. Inbound mail is absent because it is not yet a feature a customer can use.

Fences you can put up yourself

Set POSTHASTE_ALLOWED_RECIPIENTS while you are evaluating an agent, or whenever it has no business mailing the general public. It is checked inside the MCP server, so a refused recipient never reaches the API — and it reads the real address out of a Name <addr> form, so an allowed address in the display name does not smuggle a different one past it.

{
  "mcpServers": {
    "posthaste": {
      "command": "npx",
      "args": ["-y", "@posthaste/mcp-server"],
      "env": {
        "POSTHASTE_API_KEY": "ph_live_…",

        // Nothing outside this list is mailed. Checked in the MCP
        // server, before the API is called at all. Full addresses or
        // bare domains, comma-separated.
        "POSTHASTE_ALLOWED_RECIPIENTS": "[email protected],staging.example.com"
      }
    }
  }
}

And when the agent only needs to answer questions, take the write tools away entirely:

{
  "env": {
    "POSTHASTE_API_KEY": "ph_live_…",

    // Drops send_email, cancel_scheduled_email and add_suppression from
    // the tool list, whatever scopes the key holds. Read-only questions
    // ("what bounced yesterday?") against a production key, with no
    // second credential to mint.
    "POSTHASTE_READ_ONLY": "1"
  }
}

There is no sandbox. Posthaste has no test environment to fall back on: every send goes through the same accept path and the same mail servers, and lands in a real mailbox. The MCP server says so in its own standing instructions to the model, because a model that assumes otherwise experiments with real people’s inboxes.

Why we are strict about this

Every message on this platform leaves from the same IP, and Gmail and Microsoft form one opinion about it. A careless send does not only cost the account that made it — it spends a reputation shared with every other customer, and the first thing you notice is your own mail going to spam. That is also why the daily warmup cap applies to an agent exactly as it applies to your code, and why preview_email reports the remaining allowance before a send rather than after a refusal.

Reading delivery status

The half of this that is not sending. get_message answers what became of a message, and — more usefully to a model — whether that answer is final.

get_message({ id: "msg_AZLm3kQ8T2Sf9pXbNc7HrQ" })

→ msg_AZLm3kQ8T2Sf9pXbNc7HrQ: DELIVERED
  FINAL. The recipient's mail server accepted the message.

  from: [email protected]
  to: [email protected]
  subject: Your receipt
  delivery attempts: 1
  last SMTP response: code 250 — 250 2.0.0 Ok: queued as 2Jnep6D7EzkVqTlB

  waybill (append-only, hash-chained):
      1 2026-08-25T20:50:33.617Z accepted  {"source":"api",…}
      2 2026-08-25T20:50:33.676Z delivered {"mx":"…","code":"250",…}

queued and sending are labelled “not final” in so many words, so an agent waits rather than re-sending something that is merely in flight. bounced and complained are labelled final and carry the instruction not to mail the address again. Most email APIs give these the same word — “not delivered” — and conflating them is how an agent either claims success it has not earned or sends a second copy to a mailbox that already refused the first.

Where the key goes, and where it never goes

The key is read from the environment once and handed to the SDK. It is never a tool argument, never part of a tool result, and never written to the server’s log — every line of output is passed through a redactor on the way out, so even an unanticipated error message cannot carry it into a transcript that will be stored and replayed.

Give an agent the narrowest key that works, and prefer a key of its own over reusing your application’s. If a transcript is ever shared or a machine is ever compromised, the question you want to be able to answer is “what could that one key do?” — and you want the answer to be short. Scopes in full →

NextCommand lineVerify a domain, send a message and watch it to delivery from a terminal — with exit codes a script can branch on, and no way to put a key on a command line.