API Reference

Everything is JSON over HTTPS at https://maildesk.email. Machine-readable spec: GET /v0/openapi.json (no key needed). The same operations are exposed as native MCP tools โ€” see MCP.

Authentication Quickstart Polling for new mail Inboxes Messages OTP Injection safety Webhooks API keys Domains Simulated inbound MCP Errors Rate limits

Authentication

Pass your key as a bearer token on every request:

Authorization: Bearer ap_your_key_here

Keys carry scopes โ€” read, write, admin. Self-serve signup keys get read + write. Check what a key can do with GET /v0/me.

No key yet? POST /v0/signup {"email": "you@example.com"}, then trade the emailed 6-digit code via POST /v0/signup/verify {"email", "code"} โ€” the response contains your key (shown exactly once; re-verifying with the same email rotates it, which doubles as lost-key recovery). Or sign up on the landing page.

Quickstart

# 1. create an inbox โ€” returns a working address in one call
curl -X POST https://maildesk.email/v0/inboxes \
  -H "authorization: Bearer $KEY" -d '{"username":"my-agent"}'

# 2. read its mail (every inbound message carries an injection_score)
curl "https://maildesk.email/v0/inboxes/my-agent@maildesk.email/messages" \
  -H "authorization: Bearer $KEY"

# 3. send from it
curl -X POST https://maildesk.email/v0/inboxes/my-agent@maildesk.email/messages \
  -H "authorization: Bearer $KEY" \
  -d '{"to":"someone@example.com","subject":"hello","text":"from my agent"}'

# 4. grab a verification code the moment it arrives
curl "https://maildesk.email/v0/inboxes/my-agent@maildesk.email/otp" \
  -H "authorization: Bearer $KEY"

Polling for new mail

?since= is the poll cursor. It returns only messages with created_at strictly greater than the value you pass, so you never re-read a message. Keep the largest created_at you've seen and pass it back on the next tick.
cursor=0
while true; do
  batch=$(curl -s "https://maildesk.email/v0/inboxes/$INBOX/messages?direction=inbound&since=$cursor" \
    -H "authorization: Bearer $KEY")
  # ... process $batch (newest first) ...
  new_cursor=$(echo "$batch" | jq '[.[].created_at] | max // empty')
  [ -n "$new_cursor" ] && cursor=$new_cursor
  sleep 15
done

Prefer an empty-inbox workflow? DELETE /v0/messages/:id each message after handling it instead of tracking a cursor. Prefer push? Register a webhook for message.received.

Inboxes

Anywhere a path says :inbox, the inbox id and the full address are interchangeable.

POST/v0/inboxeswrite

Create an inbox. Body (all optional): username (local part โ€” lowercased, limited to a-z0-9._-, random if omitted), name (human label), domain (defaults to the default domain). Returns 201 with the Inbox, including its address. 409 if the address exists.

GET/v0/inboxesread

List all inboxes.

GET/v0/inboxes/:inboxread

Fetch one inbox.

DELETE/v0/inboxes/:inboxwrite

Delete an inbox and everything in it (messages, threads, attachments).

Messages

GET/v0/inboxes/:inbox/messagesread

List messages, newest first. Query params:

ParamTypeMeaning
sinceepoch‑msOnly messages with created_at > since โ€” the poll cursor
directioninbound | outboundFilter by direction
limit1โ€“200Batch size (default 50)
POST/v0/inboxes/:inbox/messageswrite

Send an email from this inbox. Body: to (required), subject, text, html. Returns 201 with {message, delivery}.

GET/v0/messages/:idread

One message with full body, attachments, and injection-safety analysis.

DELETE/v0/messages/:idwrite

Delete a message (and its attachments) once you've handled it โ€” it never reappears in a poll.

GET/v0/messages/:id/attachmentsread

Attachment metadata: {id, filename, content_type, size, has_content}.

GET/v0/messages/:id/attachments/:attachment_idread

Raw attachment bytes with the original content type. 410 if the content was too large to store inline (metadata only).

The Message object

FieldTypeNotes
idstringmsg_โ€ฆ
inbox_idstring
thread_idstringMessages sharing a normalized subject share a thread
directionstringinbound or outbound
from_addr / to_addrstring
subjectstring?
text / htmlstring?Real inbound mail is fully MIME-parsed (multipart, quoted-printable, charsets)
previewstring?Short plain-text excerpt for list views
injection_scoreint?0โ€“100 prompt-injection risk (inbound only)
injection_levelstring?low / medium / high
injection_flagsarray{code, weight, detail} โ€” explainable signals behind the score
otp_codestring?Auto-extracted verification code, if found
labelsarraye.g. ["otp"]
created_atepoch‑msUse as the since cursor

OTP extraction

GET/v0/inboxes/:inbox/otpread

The most recent auto-extracted verification code, or {"code": null} if none yet. Pass ?since=<epoch-ms of when you triggered the send> to ignore older codes. Response: {code, message_id, from, received_at}.

Injection safety

Every inbound message is scored 0โ€“100 for prompt-injection risk before your agent reads it. Gate tool use on it:

msg = get_message(id)
if msg.injection_level == "high":
    quarantine(msg)          # don't let the model act on it
elif msg.injection_level == "medium":
    require_confirmation(msg)
else:
    process(msg)

injection_flags names each signal (e.g. instruction_override, secret_exfiltration, look-alike sender domains, hidden HTML) with its weight โ€” the score is explainable, not a black box.

Webhooks admin

Push instead of poll. Events: message.received, message.sent, or *.

POST/v0/webhooks

Body: url (required), secret (random UUID if omitted), events (default ["*"]). Deliveries are signed: x-agentpost-signature is the hex HMAC-SHA256 of the raw body with your secret.

GET/v0/webhooks
DELETE/v0/webhooks/:id

API keys admin

POST/v0/keys

Body: name, scopes (subset of read/write/admin; default ["read","write"]). The raw key appears once in the 201 response.

GET/v0/keys
DELETE/v0/keys/:id
GET/v0/meany

What key am I? Returns {name, scopes, master}.

Domains

GET/v0/domainsread

List configured domains (readable so you can pick one when creating inboxes). Writes โ€” POST /v0/domains, PATCH/DELETE /v0/domains/:domain, POST /v0/domains/:domain/default โ€” require admin.

Simulated inbound

POST/v0/inboundwrite

Inject a fake inbound email for testing โ€” it runs the same pipeline as real mail (injection scoring, OTP extraction, webhooks). Body: from, to (must match an existing inbox), subject, text, html, optional attachments [{filename, content_type, content_b64}]. Note: text is stored verbatim โ€” real inbound mail is MIME-parsed before it reaches this pipeline.

MCP

Point any MCP client at POST https://maildesk.email/mcp with the same bearer key (plain JSON-RPC 2.0 responses โ€” the broadly compatible subset of streamable HTTP). Tools:

ToolREST equivalent
create_inboxPOST /v0/inboxes
list_inboxesGET /v0/inboxes
list_messagesGET /v0/inboxes/:inbox/messages (supports since)
get_messageGET /v0/messages/:id
send_messagePOST /v0/inboxes/:inbox/messages
delete_messageDELETE /v0/messages/:id
wait_for_otpGET /v0/inboxes/:inbox/otp
list_domainsGET /v0/domains

Errors

Errors are JSON: {"error": "<what went wrong>", "docs": "https://maildesk.email/docs"}. Resource misses name the resource ("inbox radar@โ€ฆ not found"); a path that doesn't exist returns 404 {"error": "no such route: GET /v0/whatever"} โ€” with or without a key, so the surface is probeable. 401 means missing/invalid key, 403 names the missing scope, 429 is a rate cap (see below).

Rate limits

CapSelf-serve keyAdmin-issued key
New inboxes / day / key10100
Outbound sends / day / inbox50200

Beta limits โ€” need more? hello@maildesk.email. Machine-readable pricing: /pricing.md.

AgentPost is a Shovelware product. Spec: /v0/openapi.json ยท Agent-readable summary: /llms.txt