Agent API & MCP Server
API endpoints and MCP integrations to easily connect MailFlat to AI agents (GPT, Claude, Cursor).
Endpoints
Every route below authenticates with an account key: X-API-Key: mf_live_… (create one in Agents → API keys).
| Method & path | Does | Returns |
|---|---|---|
| POST /api/v1/inboxes | Open a new agent inbox (always plain text) | { address, retention_hours, … } |
| GET /api/v1/inboxes | List every agent inbox this key can see | { inboxes: [ … ] } |
| GET /api/v1/inboxes/{addr}/messages | All messages in the inbox | { emails: [ … ] } |
| GET /api/v1/inboxes/{addr}/latest | Newest message only, the OTP polling call | { email: { … } } |
| GET /api/v1/inboxes/{addr}/messages/{id} | One message by id, and how you check what happened to a send | { email: { … } } |
| POST /api/v1/inboxes/{addr}/send | Send a DKIM-signed mail from this inbox. Answers 202: accepted, not yet delivered | { ok: true, queued: true, message_id } |
| DELETE /api/v1/inboxes/{addr} | Delete the inbox and its messages | { ok: true } |
| DELETE /api/v1/inboxes/{addr}/messages/{id} | Delete one message | { ok: true } |
| GET /api/v1/inboxes/{addr}/messages/{id}/attachments/{aid} | Download one attachment | the file itself, or the envelope on an encrypted inbox |
Message fields worth knowingattachments · headers · links · spam
| Field | Type | Meaning |
|---|---|---|
| attachments | array | Metadata only: id, filename, content_type, size_bytes, is_encrypted, truncated. The bytes come from the attachment endpoint above; putting them in every listing would make a single 5 MB file weigh down every call. |
| headers | object | Every header as it arrived. Repeating ones (notably Received) come back as a list, because the chain is the delivery path. null on an encrypted inbox: headers carry the Subject, so they travel inside the envelope instead. |
| links | array | Every http(s) URL in the message, in order, de-duplicated. Magic links without a regex. |
| spam | object | null | SpamAssassin result: score, the required threshold it was judged against, is_spam, and every rules[] entry that fired with its own weight. null means the message was never scanned, not that it is clean; 0.0 is a real result. Stays readable on encrypted inboxes: rule names carry no message content. Nothing is filtered or blocked on our side. |
Attachment limitssending: 1 file(s) · 512 KB on Free
| Sending (depends on your plan) | Free | What happens past it |
|---|---|---|
| Files per message | 1 | Rejected with 400 and the limit named. Nothing is dropped silently. |
| All files in a message | 512 KB | Same. Paid plans raise both, so read them from GET /api/plans. |
| Receiving (fixed, not plan-based) | Value | What happens past it |
|---|---|---|
| Files per message | 10 | Later files are not recorded. |
| One file | 5 MB | Kept as metadata with truncated: true. The entry stays so an oversized file is distinguishable from one that never arrived. |
| All files in a message | 10 MB | Same: the entry stays, the bytes do not. |
Downloading a truncated attachment
Returns 400 with Attachment was too large to store rather than an empty file, so a test fails loudly instead of asserting on zero bytes.
POST /api/v1/inboxes body fieldsevery field is optional
| Field | Type | Meaning |
|---|---|---|
| prefix | string | Part before the @. Omit it and one is generated for you. |
| subdomain | string | Namespace after the @ on mailflat.net. Random when omitted; paid plans only. |
| domain | string | One of your verified BYOD domains, e.g. acme.com → prefix@acme.com. |
| label | string | A human name for the inbox, shown in the dashboard. Paid plans only. On Free the inbox gets a generated name and the response says so (see below). |
| retention_hours | int | How long messages are kept. Values above your plan max are capped rather than rejected; 0 and negatives are rejected with 422. |
A field your plan does not include is reported, not dropped
label and subdomain need a paid plan. On Free the inbox is still created, but the response carries ignored_fields naming what had no effect and a note explaining why. Nothing is silently swallowed: an unknown field is a 422, and a known field your plan does not cover is applied-or-explained, never quietly ignored.
POST /api/v1/inboxes/{addr}/send body fieldsto is required
| Field | Type | Meaning |
|---|---|---|
| to | email, required | Recipient address. |
| subject | string | Defaults to empty. |
| body | string | Plain-text body. |
| html | string | Optional HTML body. Use it for templated mail; plain body alone is fine. |
| cc | array of emails | Copied recipients. They appear in the mail's headers, so everyone sees them. |
| bcc | array of emails | Blind-copied recipients. They receive the mail but never appear in its headers, not even in their own copy. |
| attachments | array | Files to attach: { filename, content_b64, content_type? }. Base64 of the raw file bytes, no data: prefix. The SDKs encode this for you. |
| in_reply_to | string | A Message-ID to answer. Keeps the mail in the same conversation instead of starting a new one. |
Attachment limits come from your plan
Total size per message and file count both depend on the plan, and Free is deliberately small. Going over returns 400 with the limit spelled out; nothing is silently dropped. Sizes are measured on the raw bytes, but base64 makes the request itself about a third larger on the wire.
send answers 202, not 200: what that changesaccepted ≠ delivered
Delivery runs on a queue, so the call returns in milliseconds and nothing has been delivered yet. The response carries message_id; the result arrives one of two ways:
• Pull: read the message back with GET .../messages/{id} and look at send_status: queued → retrying → sent | unsigned | failed(with send_error). queued means accepted but not attempted yet; retrying means an attempt was made, did not get through, and is scheduled again, and send_error says what the last attempt reported. The SDKs wrap this as wait_until_sent().
• Push: subscribe to the message.delivered /message.failed webhooks. Preferred for anything long-running: polling costs a request even when nothing changed.
A temporary failure is not the end. The queue retries with increasing delays (a greylisting recipient can hold mail for minutes), so a message still sitting at queued is on its way, not lost.
Errors you can actually hit400 · 401 · 403 · 404 · 422
| Status | When | Detail message |
|---|---|---|
| 400 | A plan or business rule blocked it | Free plan allows at most 3 agent inboxes… |
| 401 | Missing key, or an inbox key used on a v1 path | A valid API key is required |
| 403 | The key does not own that inbox | No inbox …@… for this API key… |
| 404 | That message or attachment is not in that inbox | Message 42 not found in inbox … |
| 422 | A field is missing, mistyped, or not a field at all | Invalid request: 'saison' is not a field POST /api/v1/inboxes accepts |
| 422 | A text field is too long, or is not storable text | Invalid request: 'subject' is too long (201 characters). The limit is 200 characters |
Text limits are checked before anything is queued
Every text field has a length limit and must be valid, storable text: no line breaks in header fields, no NUL, no unpaired surrogates. Going over or sending unstorable text is a 422 that names the field and the limit, at the boundary. It is never accepted with 202 and failed later: work that cannot succeed should not enter the queue, and the caller should not have to wait to find out.
detail is always a sentence, never a raw validation dump, safe to show a user or feed back to a model. The 403/404 split is deliberate: an inbox you do not own is always 403, whether or not it exists, so the API cannot be used to discover other people's addresses. Once you are inside an inbox you own, a wrong message id is an honest 404, which is what lets the SDKs distinguish “wrong id” from “wrong key”.
Unknown fields are rejected, not ignored
A field this API does not know is a 422, not a silent drop. This matters most for public_key: sending it used to return 200 and create a plaintext inbox, so you could believe you had opened an end-to-end encrypted one when you had not. Inboxes created through this API are always plaintext by design. That is what lets the API read otp_code and message bodies for you. End-to-end encryption is account-level and is set up in the dashboard.
Reads are not rate limited
Polling /latest every couple of seconds is the intended use, so there is no request ceiling on this API. Your plan quotas are the bound. Rate limiting applies only to the anonymous auth endpoints (sign-up, login, password reset), which an agent never touches.
Encrypted inboxes answer differently
If you point these routes at an end-to-end encrypted inbox, the response carries encrypted: true and a note explaining that body and otp_code are unavailable, because the server genuinely cannot read them. Inboxes created through this API are never encrypted, so this only happens when you target an inbox from the human pool.
The full loop
# 1) create a short-lived inbox (2h retention)
curl -X POST https://mailflat.net/api/v1/inboxes \
-H "X-API-Key: mf_live_…" -H "Content-Type: application/json" \
-d '{"prefix":"agent-7f3","retention_hours":2}'
# → { "address": "agent-7f3@a7f2c.mailflat.net" }
# 2) poll the latest mail (OTP auto-extracted)
curl https://mailflat.net/api/v1/inboxes/agent-7f3@a7f2c.mailflat.net/latest \
-H "X-API-Key: mf_live_…"
# → { "email": { "subject": "Your code", "otp_code": "482913" } }
# 3) send a reply from this inbox (DKIM-signed)
curl -X POST https://mailflat.net/api/v1/inboxes/agent-7f3@a7f2c.mailflat.net/send \
-H "X-API-Key: mf_live_…" -H "Content-Type: application/json" \
-d '{"to":"user@gmail.com","subject":"Re: hi","body":"Hello back!"}'
# 4) clean up when done
curl -X DELETE https://mailflat.net/api/v1/inboxes/agent-7f3@a7f2c.mailflat.net \
-H "X-API-Key: mf_live_…"
Line by linewhat each step of this curl example does
- -H "X-API-Key: …"
- Account key, the only auth this API accepts.
- retention_hours: 2
- Short window so abandoned test inboxes clean themselves up.
- /latest
- Newest message only. Poll it every 2–3s until otp_code is non-null.
- DELETE …
- Optional. Retention would purge the messages anyway, but this frees the inbox slot immediately.
The SDKs wrap exactly this loop: wait_for_otp / waitForOtp is the polling block above.
MCP server, give an AI agent inbox tools
MailFlat ships a native Model Context Protocol server. Run it inside Claude Desktop, Cursor or your own agent framework and the model gets eleven inbox tools automatically.
# no install step, uvx fetches and runs it
MAILFLAT_API_KEY=mf_live_… uvx mailflat-mcp
Eleven tools exposed
| Tool | What it does |
|---|---|
| create_inbox(prefix?, label?, retention_hours?) | Open an inbox; retention_hours capped by your plan |
| list_inboxes() | All inboxes this key can see |
| read_messages(address, direction="in") | Read messages; received mail by default, out or all on request |
| wait_for_otp(address, timeout=30) | Poll until an OTP arrives, then return it |
| wait_for_message(address, timeout=30) | Poll until a message arrives; ignores mail the agent itself sent |
| send_email(address, to, subject?, body?, html?, cc?, bcc?) | Send a DKIM-signed mail from the inbox. Accepted for delivery, not delivered |
| reply(address, message_id, body?, html?, cc?, bcc?) | Answer a message in the same conversation, filling in recipient, Re: and threading headers |
| mark_read(address, message_id) | Mark one message read so later polls can skip it |
| burn_inbox(address) | Delete every message but keep the address |
| delete_inbox(address) | Delete the inbox and its messages |
| delete_message(address, message_id) | Delete one message; the inbox itself stays |
Attachments are not a model tool
send_email and reply take cc and bcc, which are short strings a model can reasonably choose. Files are not offered here: the bytes would have to travel through the model’s context, which costs tokens and is plainly impossible for a multi-megabyte file. Attach files from the SDK instead, where it is code that decides.
Not using MCP?
Agents → Tool spec → Copy spec gives you the six core ones (create_inbox, list_inboxes, read_messages, wait_for_otp, send_email, delete_inbox) as a raw function schema for GPT, Claude or LangChain, with the same names and parameters. The rest of the list above is MCP-only for now.
GPTClaudeLangChain