Mailman API

Mailman API

A REST API to send, receive, search, and organize email programmatically. Authenticate with an mm_ key, call JSON endpoints, and (optionally) receive signed webhooks for inbound mail and delivery events.

Introduction

Every endpoint lives under /v1, speaks JSON, and is authenticated with a bearer API key. A single key belongs to one owner and is granted a set of scopes and access to one or all of the owner's mailboxes (accounts). Use it to build transactional email, inbound-mail automations, inbox tooling, or a full mail client.

Base URL

base url
https://mail.baliyang.com/v1

All paths in this reference are relative to that base. Requests are server-to-server (send the key from your backend, never from a browser). All requests must be HTTPS.

Quickstart

Send your first email in three steps.

  1. Create a key in the Mailman app (Settings → API keys). Pick scopes (at least send) and either a specific mailbox or all mailboxes. The full key is shown once — copy it.
  2. Store it as a secret in your service (e.g. MAILMAN_KEY). It looks like mm_ab12cd34_….
  3. Send:
curl
curl -X POST https://mail.baliyang.com/v1/emails \
  -H "Authorization: Bearer $MAILMAN_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: welcome-user-9f3a" \
  -d '{
    "to": [{ "address": "user@example.com", "name": "New User" }],
    "subject": "Welcome to Acme",
    "text": "Thanks for signing up!",
    "html": "<p>Thanks for signing up!</p>"
  }'
200 response
{
  "messageId": "<9f3a…@baliyang.com>",
  "threadId": 1024,
  "dbId": 50231,
  "sentAt": 1737700000000,
  "tag": null
}

Authentication

Pass your key as a bearer token on every request:

Authorization: Bearer mm_ab12cd34_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Keys have the shape mm_<prefix>_<secret>. The 8-character prefix is safe to log or display for identification; the secret is only shown once at creation and is stored SHA-256-hashed — Mailman can never show it again. Rotate by creating a new key and revoking the old one.

Create & revoke keys in the Mailman web app (they're issued behind your own login, not via this API). Revoking a key takes effect immediately — subsequent requests return 401 invalid_key.
Never expose a key in client-side code, a mobile app bundle, or a public repo. Anyone with the key can act as its owner within its scopes. Treat it like a password; keep it server-side.

Scopes

Each key carries a set of scopes. A request that needs a scope the key lacks returns 403 insufficient_scope with the required and granted fields.

ScopeGrants
sendSend email (POST /v1/emails)
inbox:readRead threads, messages, labels, filters, key context
inbox:modifyArchive / trash / read / snooze threads, manage labels & filters
searchFull-text search (GET /v1/search)
attachments:readDownload attachments
attachments:writeUpload attachments to send

Grant the minimum a service needs. A transactional-email service usually needs only send (plus attachments:write if it sends files).

Accounts (mailboxes)

An owner can have several mailboxes ("accounts"). How a request picks one:

Read the current key's owner, scopes, default account, and mailbox list from GET /v1/me.

Rate limits & quotas

Idempotency

Send POST /v1/emails with an Idempotency-Key header (any unique string, ≤ 255 chars) so retries never send twice.

Errors

Errors are JSON with a machine-readable error code and an optional human detail. Validation errors add field; scope errors add required/granted; rate limits add limit.

example error
{
  "error": "insufficient_scope",
  "required": "send",
  "granted": ["inbox:read", "search"]
}
StatusCodeMeaning
400invalid_json / missing_fields / invalid_inputBad body, missing required field, or a malformed value (see field).
400account_requiredNo accountId and the key has no default.
401unauthorized / invalid_keyMissing/malformed header, or a revoked/unknown/expired key.
403insufficient_scopeKey lacks the required scope.
403account_not_authorized_by_key / forbiddenThe key isn't allowed on that mailbox.
404not_foundThread / message / label / filter / attachment doesn't exist.
409idempotency_in_progressA send with the same Idempotency-Key is still processing.
429send_daily_limit_exceeded / key_daily_limit_exceededQuota hit — back off and retry later.
502send_failedTransient delivery failure — safe to retry (use an Idempotency-Key).
503sending_not_enabledThe sender domain isn't onboarded for sending.
Retry policy: retry 429 (after a delay) and 502 (with the same Idempotency-Key). Do not blindly retry 400/401/403 — fix the request first.

Send email

POST/v1/emailsscope send

Send a message from one of your mailboxes. Supports plain text and/or HTML, CC/BCC, reply threading, attachments, and a free-form tag.

FieldTypeNotes
toAddress[]required1–50 recipients. Each is { "address": string, "name"?: string }.
subjectstringrequiredUp to 998 bytes.
textstringrequiredPlain-text body (≤ 1 MB). Always send this, even with HTML.
htmlstringoptionalHTML body (≤ 1 MB).
accountIdnumberoptionalSending mailbox; defaults to the key's default.
cc, bccAddress[]optionalSame shape as to.
replyTostringoptionalReply-To address.
inReplyTostringoptionalA Message-ID to reply to (RFC 5322).
referencesstringoptionalSpace-separated Message-IDs for threading.
tagstringoptionalYour label for the send (echoed back & stored).
attachmentsAttachment[]optionalUp to 20. Each is { key, filename, mime, size } from the upload endpoint.
curl
curl -X POST https://mail.baliyang.com/v1/emails \
  -H "Authorization: Bearer $MAILMAN_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-4821-shipped" \
  -d '{
    "accountId": 5,
    "to": [{ "address": "buyer@example.com", "name": "Buyer" }],
    "cc": [{ "address": "ops@acme.co" }],
    "subject": "Your order shipped",
    "text": "Tracking: 1Z999…",
    "html": "<p>Tracking: <b>1Z999…</b></p>",
    "tag": "transactional"
  }'
200
{
  "messageId": "<a1b2…@baliyang.com>",
  "threadId": 2048,
  "dbId": 99123,
  "sentAt": 1737700123000,
  "tag": "transactional"
}

Get a message

GET/v1/emails/:idscope inbox:read

:id may be a numeric message id or a Message-ID header (with or without the angle brackets). Returns metadata + delivery status; fetch the full thread for bodies & attachments.

200
{
  "id": 99123,
  "threadId": 2048,
  "messageId": "<a1b2…@baliyang.com>",
  "subject": "Your order shipped",
  "direction": "out",
  "from": { "address": "shop@acme.co" },
  "to": [{ "address": "buyer@example.com" }],
  "sentAt": 1737700123000,
  "hasAttachments": false,
  "deliveryStatus": "delivered",
  "deliveryError": null,
  "tag": "transactional"
}

List threads

GET/v1/threadsscope inbox:read

Cursor-paginated list of threads in a mailbox folder.

QueryNotes
accountIdoptionalDefaults to the key's default mailbox.
folderoptionalinbox (default), sent, archive, trash, all.
limitoptionalDefault 50, max 200.
cursoroptionalPass nextCursor from the previous page.
curl -H "Authorization: Bearer $MAILMAN_KEY" \
  "https://mail.baliyang.com/v1/threads?accountId=5&folder=inbox&limit=25"
200
{
  "threads": [
    {
      "id": 2048,
      "subject": "Project sync",
      "snippet": "Let's line up Q3…",
      "participants": [{ "name": "Alice", "address": "alice@x.com" }],
      "lastMessageAt": 1737700000000,
      "messageCount": 6,
      "unreadCount": 1
    }
  ],
  "nextCursor": "1737699000000"
}

Get a thread

GET/v1/threads/:idscope inbox:read

The thread plus every message (with plain-text bodies and attachment counts).

200 (truncated)
{
  "thread": { "id": 2048, "subject": "Project sync", "unreadCount": 1, "archived": false, "trashed": false },
  "messages": [
    {
      "id": 99120,
      "direction": "in",
      "from": { "name": "Alice", "address": "alice@x.com" },
      "to": [{ "address": "you@acme.co" }],
      "subject": "Project sync",
      "text": "Full plain-text body…",
      "sentAt": 1737699000000,
      "read": true,
      "attachmentCount": 1
    }
  ]
}

Archive · Trash · Read · Snooze

POST/v1/threads/:id/archivescope inbox:modify
POST/v1/threads/:id/trash
POST/v1/threads/:id/read
POST/v1/threads/:id/snooze

Each takes a small JSON body and returns { "ok": true }.

EndpointBody
…/archive{ "archived": true } (or false to un-archive)
…/trash{ "trashed": true } (or false to restore)
…/read{ "read": true } (marks all messages read/unread)
…/snooze{ "until": 1737800000000 } (ms timestamp, or null to un-snooze)

Thread labels

GET/v1/threads/:id/labelsscope inbox:read
POST/v1/threads/:id/labelsscope inbox:modify
DELETE/v1/threads/:id/labels/:labelIdscope inbox:modify

Apply with { "labelId": 3 } (the label must belong to the thread's mailbox). All return { "ok": true }; GET returns { "labels": [{ "id", "name", "color" }] }.

Labels

GET/v1/labelsscope inbox:read
POST/v1/labelsscope inbox:modify
DELETE/v1/labels/:idscope inbox:modify

Create with { "name": "Receipts", "color": "#34d399", "accountId": 5 }. List with ?accountId=5 (or ?accountId=all for a multi-mailbox key).

GET /v1/labels → 200
{
  "labels": [
    { "id": 3, "accountId": 5, "name": "Receipts", "color": "#34d399", "threadCount": 42 }
  ]
}
GET/v1/searchscope search

Full-text search with Gmail-style operators. q is required; accountId (or all) and limit (max 100) are optional.

OperatorMatches
from:alice@x.comsender
to:me@acme.corecipient
subject:invoicesubject contains
has:attachmenthas an attachment
is:unread · is:read · is:sentstate
before:2026-01-01 · after:2025-12-01date range
curl -H "Authorization: Bearer $MAILMAN_KEY" \
  "https://mail.baliyang.com/v1/search?q=from:stripe%20is:unread&accountId=all"

Filters

GET/v1/filtersscope inbox:read
POST/v1/filtersscope inbox:modify
DELETE/v1/filters/:idscope inbox:modify
POST/v1/filters/:id/run-on/:threadIdscope inbox:modify

Filters run on inbound mail: a match (substring on from/to/subject/body, or hasAttachment) triggers an ordered list of actions. POST creates (omit id) or updates (include id); run-on applies a filter to one thread for testing.

POST /v1/filters body
{
  "accountId": 5,
  "name": "Auto-file receipts",
  "enabled": true,
  "stopOnMatch": false,
  "match": { "subject": "receipt", "hasAttachment": true },
  "actions": [
    { "type": "apply_label", "label_id": 3 },
    { "type": "mark_read" },
    { "type": "archive" }
  ]
}

Action types: apply_label (with label_id), archive, mark_read, add_to_contacts.

Attachments

POST/v1/attachments/uploadscope attachments:write
GET/v1/attachments/:idscope attachments:read

Sending a file is two steps: upload the raw bytes to get a key, then reference that key in POST /v1/emails. Up to 20 attachments per message.

1 · upload
curl -X POST "https://mail.baliyang.com/v1/attachments/upload?accountId=5" \
  -H "Authorization: Bearer $MAILMAN_KEY" \
  -H "Content-Type: application/pdf" \
  -H "X-Filename: invoice.pdf" \
  --data-binary @invoice.pdf
→ 200
{
  "key": "5/uploads/9f3a…-invoice.pdf",
  "filename": "invoice.pdf",
  "mime": "application/pdf",
  "size": 102400
}
2 · attach in send
{
  "to": [{ "address": "buyer@example.com" }],
  "subject": "Your invoice",
  "text": "Attached.",
  "attachments": [
    { "key": "5/uploads/9f3a…-invoice.pdf", "filename": "invoice.pdf", "mime": "application/pdf", "size": 102400 }
  ]
}

Download an inbound attachment by its id (found on a message): GET /v1/attachments/:id returns the raw bytes with a Content-Disposition filename.

Key context

GET/v1/meno scope required

Introspect the current key — owner, scopes, default mailbox, and the mailboxes it can reach. Useful at startup to validate a key and discover account ids.

200
{
  "owner": { "id": 1, "email": "you@acme.co", "displayName": "Acme" },
  "key": { "id": 42, "scopes": ["send", "inbox:read"], "allAccounts": false, "defaultAccountId": 5, "dailyLimit": null },
  "accounts": [
    { "id": 5, "address": "shop@acme.co", "displayName": "Shop" }
  ]
}

Webhooks

Register an HTTPS endpoint in the Mailman app to receive events for inbound mail and outbound delivery. Every delivery is signed so you can verify it came from Mailman.

Events

TypeFires when
mail.receivedAn inbound message arrives
email.queued · email.deliveredOutbound send accepted / delivered
email.bounced · email.complaintDelivery failed / spam complaint
thread.archived · thread.trashed · thread.read_changedThread state changed
message.readA message was marked read

Delivery

Mailman POSTs JSON to your URL with these headers:

Content-Type: application/json
X-Mailman-Event-Id: 7f1c…            # unique per event
X-Mailman-Event-Type: email.delivered
X-Mailman-Attempt: 1                 # 1–5
X-Mailman-Signature: t=1737700000,v1=<hex-hmac-sha256>
payload
{
  "id": "7f1c…",
  "type": "email.delivered",
  "createdAt": 1737700000000,
  "data": { "messageId": "<a1b2…>", "threadId": 2048, "accountId": 5, "deliveryStatus": "delivered" }
}

Verify the signature

The signature is HMAC-SHA256("<t>.<raw-body>") using the endpoint's signing secret (a hex string shown once when you create the endpoint). Compute it and constant-time-compare to the v1= value. Reject if t is older than ~5 minutes.

node
import crypto from "node:crypto";

function verify(rawBody, header, secret) {
  const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const ok = crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
  const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
  return ok && fresh;
}
Retries: non-2xx responses are retried 5× with backoff (0s, 30s, 5m, 30m, 2h). After 5 consecutive failures the endpoint is auto-disabled. Return 2xx quickly and process asynchronously; dedupe on X-Mailman-Event-Id.

Wire up a service

A minimal transactional-email helper in Node — the pattern most services need:

mailman.js
const BASE = "https://mail.baliyang.com/v1";
const KEY = process.env.MAILMAN_KEY;  // mm_… kept server-side

export async function sendEmail({ to, subject, text, html, idempotencyKey }) {
  const res = await fetch(`${BASE}/emails`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${KEY}`,
      "Content-Type": "application/json",
      ...(idempotencyKey && { "Idempotency-Key": idempotencyKey }),
    },
    body: JSON.stringify({ to, subject, text, html }),
  });
  const body = await res.json();
  if (!res.ok) throw new Error(`mailman ${res.status}: ${body.error}`);
  return body;  // { messageId, threadId, sentAt, … }
}

// usage — idempotent per business event
await sendEmail({
  to: [{ address: user.email, name: user.name }],
  subject: "Password reset",
  text: `Reset: ${link}`,
  idempotencyKey: `pwreset-${resetToken}`,
});

Mailman API · v1 · base https://mail.baliyang.com/v1