paulobrien.dev

Mail App API

Mail App API and webhooks.

Read, search and send email from your own apps, and get told the moment new email arrives. The API runs on your own Mail App, in your own Cloudflare account, so your mail never passes through anyone else.

1Get started

The API comes with Mail App. It lives on your own install, at your own web address:

https://mail.yourdomain.co.uk/api/ext/v1
  1. Open API & webhooksSign in to Mail App as an admin and open Admin › API & webhooks. Your API address is there, ready to copy.
  2. Switch it on if askedIf it says the API isn't switched on yet, follow The API and download links in the README. The installer does it for you. It lets just the API's address past Cloudflare sign-in. The rest of Mail App stays behind sign-in.
  3. Make a keyChoose New API key, tick what the key can do and which mailboxes it can use, and copy the key. It's shown once.
  4. Send it with each requestIn an Authorization header, like this:
curl https://mail.yourdomain.co.uk/api/ext/v1/mailboxes \
  -H "Authorization: Bearer mailapp_ab12cd34_••••••••••••••••••••••••••••••••"

2Keys and permissions

Make a separate key for each app, so you can delete one without stopping the others. Each key only reaches the mailboxes you ticked (any other mailbox looks as if it doesn't exist), and only does what you allowed:

PermissionWhat it allows
ReadList mailboxes, list, read and search email, read whole conversations, see folders and labels, and download attachments.
SendSend new emails, replies and forwards from the key's mailboxes (and their extra addresses).
OrganiseMark email as read or unread, star it, move it to a folder, and add or remove labels.
Safe by design. The API never deletes email, changes settings, or manages people, mailboxes or domains. Those stay in the app, behind sign-in.

3Every request

Write mailbox addresses in the path with @ as %40, for example /mailboxes/sales%40yourdomain.co.uk/emails.

RequestNeedsWhat it does
GET /mailboxesReadThe mailboxes this key can use.
GET /mailboxes/{mailbox}/foldersReadFolders, with how many unread emails each has. The built-in ones are inbox, sent, draft, archive, spam and trash (shown as Bin).
GET /mailboxes/{mailbox}/emailsReadA page of emails in a folder, newest first, with totalCount. Takes folder, page and limit. Add threaded=true for one row per conversation.
GET /mailboxes/{mailbox}/emails/{id}ReadOne email in full, with its body and attachments list.
GET /mailboxes/{mailbox}/threads/{thread_id}ReadEvery email in a conversation, oldest first.
GET /mailboxes/{mailbox}/search?query=invoiceReadSearch. Also takes from, to, subject, folder, label, date_start, date_end, is_read, is_starred, has_attachment, page and limit.
GET /mailboxes/{mailbox}/labelsReadThe mailbox's labels.
GET …/emails/{id}/attachments/{attachment_id}ReadDownloads an attachment (the file itself, not JSON).
POST /mailboxes/{mailbox}/emailsSendSends a new email. See the example below.
POST …/emails/{id}/replySendReplies to an email, keeping it in the same conversation. Same body as sending.
POST …/emails/{id}/forwardSendForwards an email. Same body as sending.
PUT …/emails/{id}OrganiseMarks as read or starred: {"read": true, "starred": false}.
POST …/emails/{id}/moveOrganiseMoves to a folder: {"folderId": "archive"}.
PUT …/emails/{id}/labelsOrganiseAdds and removes labels on the whole conversation: {"add": ["label-id"], "remove": []}.

4Examples

Read the newest emails in an inbox

curl "https://mail.yourdomain.co.uk/api/ext/v1/mailboxes/sales%40yourdomain.co.uk/emails?folder=inbox&limit=10" \
  -H "Authorization: Bearer $MAIL_API_KEY"

The answer

{
  "emails": [
    {
      "id": "f332437e-582c-4e8f-b514-db180c5e2a91",
      "thread_id": "f332437e-582c-4e8f-b514-db180c5e2a91",
      "subject": "Order 1234 question",
      "sender": "jo@example.org",
      "sender_name": "Jo Bloggs",
      "recipient": "sales@yourdomain.co.uk",
      "date": "2026-10-15T09:30:00.000Z",
      "read": false,
      "starred": false
    }
  ],
  "totalCount": 1
}

Send an email

curl https://mail.yourdomain.co.uk/api/ext/v1/mailboxes/sales%40yourdomain.co.uk/emails \
  -H "Authorization: Bearer $MAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": { "email": "sales@yourdomain.co.uk", "name": "Greenleaf Sales" },
    "to": "jo@example.org",
    "subject": "Thanks for your order",
    "text": "We have your order and will be in touch soon."
  }'

to, cc and bcc take one address or a list. from must be the mailbox or one of its extra addresses. To attach a file, add "attachments": [{"filename": "invoice.pdf", "type": "application/pdf", "content": "<base64>"}]. The answer is {"id": "…", "status": "sent"}, and the email shows in the Sent folder like any other.

Sending needs Workers Paid. Sending to other people needs Cloudflare's Workers Paid plan ($5 a month) and your domain set up for sending, the same as sending from the app. If Cloudflare refuses an email, the answer says why.

In JavaScript

const res = await fetch(`${MAIL_API_URL}/mailboxes/${encodeURIComponent("sales@yourdomain.co.uk")}/search?query=invoice`, {
  headers: { Authorization: `Bearer ${process.env.MAIL_API_KEY}` },
});
if (!res.ok) throw new Error((await res.json()).error);
const { emails } = await res.json();

5Webhooks

A webhook tells another app's web address the moment something happens, so it doesn't have to keep asking. Add one in Admin › API & webhooks › New webhook: give its https:// address, tick when to send it, and choose all mailboxes or just some.

EventWhen it's sent
email.receivedAn email arrives in a mailbox. Not for spam, or for email an inbox rule moves to the Bin.
email.sentAn email, reply or forward is sent from a mailbox, from the app or the API.

What Mail App sends

{
  "id": "evt_c7e1c6da-0e04-4a56-94b8-9e9ca5be6b70",
  "type": "email.received",
  "created": "2026-10-15T09:30:01.000Z",
  "mailbox": "sales@yourdomain.co.uk",
  "data": {
    "email": {
      "id": "f332437e-582c-4e8f-b514-db180c5e2a91",
      "thread_id": "f332437e-582c-4e8f-b514-db180c5e2a91",
      "folder": "inbox",
      "subject": "Order 1234 question",
      "from": "jo@example.org",
      "from_name": "Jo Bloggs",
      "to": "sales@yourdomain.co.uk",
      "date": "2026-10-15T09:30:00.000Z",
      "has_attachments": false
    }
  }
}

The body is a summary, not the whole email, so private mail isn't copied around more than it needs to be. Use the API with the email's id to read the rest. Each request also carries Mail-App-Event (the event, or test for the Send a test button), Mail-App-Delivery (the same for every try, so you can ignore repeats) and Mail-App-Signature.

Answer with any 2xx status within 10 seconds. If your app can't be reached, takes too long, or answers 408, 429 or 5xx, Mail App tries again after 1 minute, 10 minutes and 1 hour. Other 4xx answers aren't tried again.

Every try shows in the webhook's Delivery log, and you can pause a webhook at any time.

Checking a webhook is genuine

Each webhook has its own signing secret (starting whsec_), shown when you add it. The app signs <t>.<body> with HMAC-SHA256 using that secret, and sends the result in the Mail-App-Signature header as t=1760520601,v1=5f2b…. To check a request, work out the same signature from the raw body, compare it with v1 and refuse anything more than five minutes old.

Node.js

import crypto from "node:crypto";

function isFromMailApp(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const age = Math.abs(Date.now() / 1000 - Number(parts.t));
  if (!parts.v1 || age > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

Python

import hashlib, hmac, time

def is_from_mail_app(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    if "v1" not in parts or abs(time.time() - int(parts["t"])) > 300:
        return False
    signed = parts["t"].encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

Use the body exactly as it arrived. Turning the JSON into an object and back into text can change it, and then the signature won't match.

6AI connector

Admins can connect an AI tool such as Claude to Mail App at https://mail.yourdomain.co.uk/mcp. It stays behind Mail App's sign-in, so only admins can use it, and the tool then reads the mail you ask it to, under its own maker's privacy terms.

Mail App's own AI reply assistant is separate and stays off unless you switch it on. See AI reply assistant.

7Errors and limits

Anything that goes wrong comes back with a status, a sentence you can show to a person and a link to this page:

{"error": "That API key isn't valid. It may have been deleted.", "docs": "https://paulobrien.dev/mail/api/"}
StatusWhat it means
400Something in the request isn't right, such as a from address the mailbox can't send as.
401No key, or a key that isn't valid any more.
403The key doesn't have the permission this needs.
404Not found, a mailbox the key can't use, or a request the API doesn't have.
413The email is too big to send. Cloudflare allows up to 5 MB per email once encoded, which is about 3.5 MB of attachments.
429Too many emails sent too quickly. Wait a little and try again.
502Cloudflare refused to send the email. The error says why.
503The API isn't switched on for this install yet.

Each install can have up to 25 API keys and 10 webhooks. Requests from a web page in someone's browser aren't allowed, because the key would be visible to anyone using the page. Call the API from your own server, or from tools like Zapier, Make or n8n.

Questions or ideas? Ask in GitHub Discussions, or open an issue for a bug.

Stay in the loop

Subscribe for updates.

Leave your email and I'll tell you when there's a new version of one of my apps, or a new project to try. Nothing else.

That didn't work. Please check your email address and try again.

One email to confirm. Unsubscribe with one click. Your address is only used for these updates.