Basehttps://api.maylee.app/api/v1
AuthorizationBearer mk_…
Content-Typeapplication/json
Scopesread, write

Maylee API

Programmatic access to a Maylee workspace: read, search, classify and send email across every connected mailbox, from your own code or from an AI agent.

Base URL        https://api.maylee.app/api/v1
Authentication  Authorization: Bearer mk_…
Content type    application/json

The machine-readable reference is openapi.json, served without authentication on GET /api/v1/openapi.json. The pages below cover what a specification cannot: intent, examples, pitfalls and the decisions behind them.

Page What you will find
Getting started Your first request, and a reply in a thread, in ten minutes
Reference Every endpoint with a request, a response and its errors
Filters The filter DSL, its bounds and six worked examples
Webhooks Events, payloads, signature verification, retries
Errors Every error code and what to do about it

Five things to know before you start

The workspace comes from the key, never from the URL. No path carries a workspace identifier. A key can only reach the data of the workspace it was created in, even if the URL is forged.

Session cookies are never accepted on /api/v1/*. Only Authorization: Bearer mk_… works. This is structural: the public surface is mounted on a router that does not know about session authentication, which is what makes it immune to CSRF.

The secret of a key is shown once, at creation. Only its SHA-256 is stored. If you lose it, create a new key.

Making email leave the workspace requires two permissions, not one: the write scope and the "outbound sending" option enabled on the key. That covers sending an email, creating an auto-forward rule, and turning on auto-reply on an AI label rule. These are the actions that reach a third party, and write alone must not be enough if a key leaks.

Every request is bounded. Requests per minute, requests per month, filter complexity, execution time and body size all have hard limits, and every authenticated response carries X-RateLimit-* headers so that you can adapt before hitting them.

Authentication

export MAYLEE_KEY="mk_…"

curl https://api.maylee.app/api/v1/mailboxes \
  -H "Authorization: Bearer $MAYLEE_KEY"

A key looks like mk_ followed by 40 alphanumeric characters. Anything else is rejected before reaching the database.

Two scopes only:

Scope Grants
read list and read emails, drafts, mailboxes, labels, AI label rules, views, inbox filters, auto-forward rules and their logs
write mark as read or starred, move to trash, apply labels, create drafts, create and manage labels, AI label rules, views and inbox filters, run a classification backfill

Outbound sending is not a scope but a separate option on the key. It gates everything that makes email leave the workspace: sending, auto-forward rules, and enabling auto-reply. A key meant to classify messages must not be able to write to your customers' inboxes, or to forward their mail elsewhere, if it is compromised. Keys are created and revoked from the Maylee app (Settings, API keys), by workspace owners and admins only.

Pagination

GET /emails and GET /emails/search return pages:

{
	"items": [],
	"nextCursor": "2026-08-12T10:30:00.000Z|cmemx1a2b3c4d5e6f7g8h9i0",
	"hasMore": true
}

Pass nextCursor back as ?cursor= to fetch the next page. It is null on the last page, never absent, so you do not have to distinguish "no next page" from "missing field". Treat the cursor as opaque: its format is not part of the contract.

Reference lists are short by nature and are not paginated: GET /mailboxes, GET /labels, GET /views, GET /ai-label-rules, GET /auto-forward-rules and GET /drafts return { "items": [] } only, without nextCursor or hasMore. GET /drafts accepts ?limit= and returns the most recent drafts up to that limit.

Sorting

Lists are ordered by date, most recent first. Search results are ordered by relevance, then by date. There is no parameter to change the order.

Quotas and rate limits

Plan Requests / minute Requests / month Active keys Active webhooks
FREE 60 10,000 2 1
PREMIUM 300 200,000 10 5
EXPERT 1,000 1,000,000 50 20

The plan that applies is the one currently in force: an expired subscription falls back to the FREE limits, even though the keys already issued keep working. "Active keys" and "Active webhooks" are enforced at creation; beyond the limit you get a 409 inviting you to revoke one.

Every authenticated response carries six headers:

X-RateLimit-Limit: 300              requests allowed per minute
X-RateLimit-Remaining: 287          left in the current minute
X-RateLimit-Reset: 1754995200       when the minute window resets (Unix seconds)
X-RateLimit-Quota: 200000           requests allowed this month
X-RateLimit-Quota-Remaining: 199000 left this month
X-RateLimit-Quota-Reset: 1756684800 first day of next month, 00:00 UTC (Unix seconds)

They are present on refusals too (400, 404, 429), but not on 401: the key is resolved before the counters are read.

Two distinct refusals, not to be confused:

  • 429 RATE_LIMIT_EXCEEDED: too many requests this minute. Wait for the number of seconds given in Retry-After.
  • 429 QUOTA_EXCEEDED: the monthly allowance is exhausted. Waiting will not help before the 1st of next month (UTC); upgrade the plan instead.

How usage is counted. One request costs one unit. Bulk operations cost one unit per email processed: a POST /emails/bulk on 200 emails costs 200 units. Webhook deliveries are initiated by Maylee and cost nothing. Usage is counted when the request is accepted, not when it completes.

An exhausted API quota only blocks /api/v1/*. The Maylee app and its real-time updates keep working.

Idempotency

Every POST that sends or creates something (/drafts/send, /drafts, /emails/bulk, /labels, /ai-label-rules, /views, /auto-forward-rules) accepts an Idempotency-Key header:

curl -X POST https://api.maylee.app/api/v1/drafts/send \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"mailboxId":"cmbx1a2b3c4d5e6f7g8h9i0j","to":[{"email":"client@acme.com"}],"body":{"text":"Hello"}}'

The response is stored for 24 hours. Replaying the same key returns the original response with the header Idempotent-Replay: true, without executing the operation again. The replay itself still counts as a request: it is subject to the per-minute rate limit and consumes the same quota units as the original call.

This matters if you drive the API from an agent: a network timeout does not mean the send failed. Without an idempotency key, retrying would deliver the message a second time.

Two guard rails:

  • Reusing a key with a different request body returns 409 CONFLICT. It is almost always a bug (a key generated once and reused in a loop), and replaying would hide it.
  • A key whose original call is still in progress returns 409 CONFLICT. Retry in a moment.

Only successful responses are stored: a failed call releases its key so that you can retry once the request is fixed.

Keys are scoped to the workspace. Use globally unique values such as UUIDs, never a counter, so that two integrations sharing a workspace cannot collide.

Limits

These values are enforced server-side.

Limit Value
Emails per bulk operation 500
limit per page 200 (default 50)
Filter tree depth 5 levels
Conditions per filter tree 50
Length of a condition value 500 characters
Raw filters parameter 8 KB
Search term 2 to 200 characters
Request execution time 10 seconds
JSON request body 256 KB

A request that exceeds the execution time is cancelled on the database side and returns 408 QUERY_TIMEOUT. Narrow the filters, lower limit, or target a single mailbox. A body above 256 KB returns 413 PAYLOAD_TOO_LARGE.

Getting started

Goal: read an email, then reply to it in the same thread, in ten minutes.

1. Create a key

In Maylee, open Settings, then API keys.

Name the key after what it is for ("CRM integration"), not after yourself: that name is what you will read the day you need to revoke one.

Tick what the integration needs:

  • read: list and read emails, drafts, mailboxes, labels and views
  • write: mark as read or starred, move to trash, bulk operations
  • Outbound sending: a separate option, to enable only if the integration really sends email

You can also give the key an expiry date. The secret is displayed once. Copy it now: it is not stored anywhere and can never be shown again.

Only workspace owners and admins can create keys.

2. Your first request

export MAYLEE_KEY="mk_yourKey"

curl https://api.maylee.app/api/v1/mailboxes \
  -H "Authorization: Bearer $MAYLEE_KEY"
{
	"items": [
		{
			"id": "cmbx1a2b3c4d5e6f7g8h9i0j",
			"email": "contact@your-company.com",
			"name": "Contact",
			"picture": null,
			"provider": "GOOGLE",
			"isActive": true,
			"createdAt": "2026-06-02T09:14:31.000Z",
			"lastSyncAt": "2026-08-25T14:02:11.000Z",
			"syncError": null,
			"backwardSyncComplete": true
		}
	]
}

Always start here: the mailboxId is required to send, and used by most filters.

3. List the latest emails

curl -G https://api.maylee.app/api/v1/emails \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  --data-urlencode 'mailboxId=cmbx1a2b3c4d5e6f7g8h9i0j' \
  --data-urlencode 'folderType=INBOX' \
  --data-urlencode 'limit=10'
{
	"items": [
		{
			"id": "cmemx1a2b3c4d5e6f7g8h9i0",
			"mailboxId": "cmbx1a2b3c4d5e6f7g8h9i0j",
			"folderId": "cmfd1a2b3c4d5e6f7g8h9i0j",
			"messageId": "<CAF3k9x1@mail.example.com>",
			"threadId": "18c2f5a9e7b1d3c4",
			"subject": "Quote request",
			"from": { "email": "client@acme.com", "name": "Jane Client" },
			"to": [{ "email": "contact@your-company.com", "name": null }],
			"cc": [],
			"date": "2026-08-25T13:47:02.000Z",
			"snippet": "Hello, could you send us a quote for…",
			"isRead": false,
			"isStarred": false,
			"isDraft": false,
			"waitingForReply": true,
			"hasAttachments": false,
			"attachments": [],
			"tags": [
				{
					"id": "cmtgai1b2c3d4e5f6g7h8i9j",
					"name": "Sales",
					"color": "#2563eb",
					"isAiLabel": true
				}
			],
			"inReplyTo": null,
			"references": []
		}
	],
	"nextCursor": "2026-08-25T13:47:02.000Z|cmemx1a2b3c4d5e6f7g8h9i0",
	"hasMore": true
}

The body is not part of the list: it costs a round trip to storage. Fetch it on demand.

4. Read the body and the thread

curl https://api.maylee.app/api/v1/emails/cmemx1a2b3c4d5e6f7g8h9i0/body \
  -H "Authorization: Bearer $MAYLEE_KEY"
{
	"emailId": "cmemx1a2b3c4d5e6f7g8h9i0",
	"html": "<p>Hello, could you send us a quote for…</p>",
	"text": "Hello, could you send us a quote for…",
	"thread": [
		{
			"id": "cmemx1a2b3c4d5e6f7g8h9i0",
			"messageId": "<CAF3k9x1@mail.example.com>",
			"references": [],
			"fromAddress": "client@acme.com",
			"fromName": "Jane Client",
			"toAddresses": [{ "email": "contact@your-company.com" }],
			"date": "2026-08-25T13:47:02.000Z",
			"subject": "Quote request",
			"html": "<p>Hello, could you send us a quote for…</p>",
			"text": "Hello, could you send us a quote for…",
			"isMe": false
		}
	]
}

html and text are the body of the email you asked for. thread holds every message of the conversation, oldest first, including your own replies and any draft in progress.

curl -G https://api.maylee.app/api/v1/emails/search \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  --data-urlencode 'q=quote'

Two to 200 characters. The search covers the subject, the sender name and address, the preview snippet and the recipients, not the full body. Results are ordered by relevance, subject matches first.

The search spans all folders: whoever looks for an invoice does not know whether it is still in the inbox. Add folderType=SENT to narrow it down.

For structured criteria rather than a keyword, see the filter DSL.

6. Mark as read

curl -X PATCH https://api.maylee.app/api/v1/emails/cmemx1a2b3c4d5e6f7g8h9i0 \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"isRead": true}'

The change is pushed in real time: a Maylee window that is open sees it immediately, and your email.updated webhooks are notified.

7. Send an email

Sending needs the write scope and the outbound sending option on the key. With the scope alone you get 403 OUTBOUND_SEND_NOT_ALLOWED.

curl -X POST https://api.maylee.app/api/v1/drafts/send \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "mailboxId": "cmbx1a2b3c4d5e6f7g8h9i0j",
    "to": [{ "email": "client@acme.com", "name": "Jane Client" }],
    "subject": "Your quote",
    "body": { "text": "Hello,\n\nPlease find the requested quote attached." }
  }'
{
	"messageId": "<20260825140233.1a2b3c@your-company.com>",
	"threadId": "18c2f5a9e7b1d3c4"
}

Always send an Idempotency-Key. A network timeout does not mean the send failed: without the key, retrying delivers the message a second time.

Prefer a human review? Create a draft instead

When the email should be checked by a person before it goes out, create a draft rather than sending. It takes the same fields, minus the outbound sending option: the write scope is enough, because nothing leaves the workspace.

curl -X POST https://api.maylee.app/api/v1/drafts \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "mailboxId": "cmbx1a2b3c4d5e6f7g8h9i0j",
    "to": [{ "email": "client@acme.com", "name": "Jane Client" }],
    "subject": "Your quote",
    "body": { "text": "Hello,\n\nPlease find the requested quote attached." }
  }'

The draft appears immediately in the Drafts folder of the Maylee app, where the user reviews it, edits it and sends it. The response is the draft itself, with isDraft: true; see POST /drafts for the details. The threading fields of the next section work the same way here.

8. Reply in a thread

This is the request every agent ends up making. A reply is a send with three extra fields taken from the original email, so that the recipient's client groups it in the same conversation.

First read the original:

curl https://api.maylee.app/api/v1/emails/cmemx1a2b3c4d5e6f7g8h9i0 \
  -H "Authorization: Bearer $MAYLEE_KEY"

Keep its mailboxId, messageId, references, from and subject. Then send:

curl -X POST https://api.maylee.app/api/v1/drafts/send \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "mailboxId": "cmbx1a2b3c4d5e6f7g8h9i0j",
    "to": [{ "email": "client@acme.com", "name": "Jane Client" }],
    "subject": "Re: Quote request",
    "inReplyTo": "<CAF3k9x1@mail.example.com>",
    "references": ["<CAF3k9x1@mail.example.com>"],
    "body": { "text": "Hello Jane,\n\nHere is the quote you asked for." }
  }'

The rules:

  • inReplyTo is the messageId of the email you answer.
  • references is the original's references array plus its messageId, in that order. On a first reply the original has no references, so the array holds the single messageId.
  • Reply to from of the original, and prefix the subject with Re: unless it already starts with it.
  • Send from the same mailboxId the original arrived in.

The response's threadId is the provider's conversation id when the provider exposes one; Maylee's own thread view relies on the headers above and works either way.

9. Let Maylee classify for you

Everything you would configure by hand in the app can be configured through the API. Two calls set up an AI label and a view that shows it:

curl -X POST https://api.maylee.app/api/v1/ai-label-rules \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "name": "Prospecting",
    "color": "#7c3aed",
    "condition": "An unsolicited sales or partnership pitch from someone we have never worked with."
  }'

Keep the label.id from the response, then:

curl -X POST https://api.maylee.app/api/v1/views \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "name": "Prospecting",
    "icon": "📣",
    "filters": { "id": "c1", "label": "ai_label", "type": "contains", "value": "<label.id>" }
  }'

From now on, every unsolicited pitch is labelled on arrival and grouped in the view. Rules apply to emails received after their creation; to classify the emails already there, call POST /ai-label-rules/backfill once. Add "hideFromInbox": true to the rule and those pitches leave the main inbox altogether, still reachable in the view. The full set of configuration endpoints (labels, AI label rules, views, inbox filters, auto-forward rules) is in the reference.

What next

Do not poll the API in a loop. To react to new email, register a webhook: cheaper in quota, and without latency.

Branch your logic on code, never on error. Messages are written for humans and may be reworded; codes are a contract. See the errors.

Read the quota headers. X-RateLimit-Remaining tells you when to slow down before you hit a 429.

The full reference is in openapi.json, also served on GET /api/v1/openapi.json without authentication, so that you can generate a client before you even have a key. Every endpoint is described with examples in the reference.

Endpoints

The complete machine-readable reference is served on GET /api/v1/openapi.json, without authentication, so that you can generate a client before you even have a key. Every endpoint is detailed below.

MethodPathScopeSummary
DEL/ai-label-rules/{ruleId}writeDelete an AI label rule
DEL/auto-forward-rules/{ruleId}writeDelete an auto-forward rule
DEL/emails/{emailId}writeMove an email to trash
DEL/emails/{emailId}/labels/{labelId}writeRemove a label from an email
DEL/filters/{filterId}writeDelete an inbox filter
DEL/labels/{labelId}writeDelete a label
DEL/views/{viewId}writeDelete a view
GET/ai-label-rulesreadList AI label rules
GET/ai-label-rules/{ruleId}readGet an AI label rule
GET/auto-forward-rulesreadList auto-forward rules
GET/auto-forward-rules/{ruleId}readGet an auto-forward rule
GET/auto-forward-rules/{ruleId}/logsreadList the forwarding attempts of a rule
GET/draftsreadList drafts
GET/drafts/{draftId}readGet a draft
GET/emailsreadList emails
GET/emails/{emailId}readGet an email
GET/emails/{emailId}/bodyreadGet an email body and its thread
GET/emails/searchreadFull-text search
GET/filtersreadList inbox filters
GET/filters/{filterId}readGet an inbox filter
GET/labelsreadList labels
GET/labels/{labelId}readGet a label
GET/mailboxesreadList connected mailboxes
GET/signaturesreadList signatures
GET/viewsreadList saved views
GET/views/{viewId}readGet a view
PATCH/ai-label-rules/{ruleId}writeUpdate an AI label rule
PATCH/auto-forward-rules/{ruleId}writeUpdate an auto-forward rule
PATCH/emails/{emailId}writeUpdate read or starred state
PATCH/filters/{filterId}writeUpdate an inbox filter
PATCH/labels/{labelId}writeRename or recolor a label
PATCH/views/{viewId}writeUpdate a view
POST/ai-label-ruleswriteCreate an AI label rule
POST/ai-label-rules/backfillwriteClassify existing emails with the AI label rules
POST/auto-forward-ruleswriteCreate an auto-forward rule
POST/draftswriteCreate a draft
POST/drafts/sendwriteSend an email
POST/emails/bulkwriteBulk mark-read, delete, apply or remove a label
POST/emails/{emailId}/labels/{labelId}writeApply a label to an email
POST/filterswriteCreate an inbox filter
POST/labelswriteCreate a label
POST/viewswriteCreate a view

Reference

Every endpoint, with a request, a response and the errors it can return. All examples assume:

Base URL        https://api.maylee.app/api/v1
Header          Authorization: Bearer $MAYLEE_KEY
Content type    application/json

Conventions shared by every endpoint:

  • Scope: the scope the key must hold. Without it: 403 INSUFFICIENT_SCOPE.
  • Cost: quota units consumed by one call. 1 unless stated otherwise.
  • Identifiers are opaque strings. Dates are ISO 8601 in UTC.
  • A resource that belongs to another workspace returns 404, never 403: a 403 would confirm that it exists.
  • Errors that apply everywhere are not repeated below: 401 (missing, invalid, revoked or expired key), 403 WORKSPACE_SUSPENDED, 429 (rate limit or monthly quota), 413 PAYLOAD_TOO_LARGE (JSON body above 256 KB) and 500 INTERNAL_ERROR. See Errors.

Emails

GET /emails

List emails, most recent first. Scope read. Cost 1.

Use it to browse a mailbox, a folder or a saved view. For keyword lookup use GET /emails/search instead.

Query parameter Type Description
mailboxId string Restrict to one mailbox. Omitted, every mailbox of the workspace is considered.
folderType string One of INBOX, SENT, DRAFTS, SPAM, TRASH. Any other value is rejected. Omitted, every folder is considered, trash and spam included: pass INBOX to read incoming mail only.
viewId string Apply a saved view. Get ids from GET /views. An id that does not exist in the workspace is rejected.
filtered string only lists the INBOX emails hidden by inbox filters and by hideFromInbox AI labels: the app's "Filtered emails". The plain INBOX listing never returns them; views are never affected. Any other value is rejected.
filters string JSON-encoded filter tree, see Filters. Max 8 KB, 5 levels, 50 conditions.
search string Optional keyword filter, applied on top of the other criteria. Shorter than 2 characters: ignored. Longer than 200: rejected.
cursor string nextCursor of the previous page.
limit integer Page size, 1 to 200. Default 50. Invalid values fall back to the default; values above 200 are clamped.

All criteria combine with AND.

curl -G https://api.maylee.app/api/v1/emails \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  --data-urlencode 'mailboxId=cmbx1a2b3c4d5e6f7g8h9i0j' \
  --data-urlencode 'folderType=INBOX' \
  --data-urlencode 'limit=2'
{
	"items": [
		{
			"id": "cmemx1a2b3c4d5e6f7g8h9i0",
			"mailboxId": "cmbx1a2b3c4d5e6f7g8h9i0j",
			"folderId": "cmfd1a2b3c4d5e6f7g8h9i0j",
			"messageId": "<CAF3k9x1@mail.example.com>",
			"threadId": "18c2f5a9e7b1d3c4",
			"subject": "Quote request",
			"from": { "email": "client@acme.com", "name": "Jane Client" },
			"to": [{ "email": "contact@your-company.com", "name": null }],
			"cc": [],
			"date": "2026-08-25T13:47:02.000Z",
			"snippet": "Hello, could you send us a quote for…",
			"isRead": false,
			"isStarred": false,
			"isDraft": false,
			"waitingForReply": true,
			"hasAttachments": true,
			"attachments": [
				{
					"filename": "brief.pdf",
					"size": 48213,
					"mimeType": "application/pdf"
				}
			],
			"tags": [
				{
					"id": "cmtgai1b2c3d4e5f6g7h8i9j",
					"name": "Sales",
					"color": "#2563eb",
					"isAiLabel": true
				}
			],
			"inReplyTo": null,
			"references": []
		}
	],
	"nextCursor": "2026-08-25T13:47:02.000Z|cmemx1a2b3c4d5e6f7g8h9i0",
	"hasMore": true
}

Each item is an EmailListItem. The body is never included: fetch it with GET /emails/{emailId}/body.

Status Code When
400 VALIDATION_FAILED Unknown folderType, invalid filter tree or bound exceeded, search longer than 200 characters. details names the parameter.
408 QUERY_TIMEOUT The query ran for more than 10 seconds and was cancelled. Narrow the filters or the mailbox.

GET /emails/search

Keyword search, ordered by relevance. Scope read. Cost 1.

Query parameter Type Description
q string Required. 2 to 200 characters.
mailboxId string Restrict to one mailbox.
folderType string One of INBOX, SENT, DRAFTS, SPAM, TRASH. Omitted, all folders are searched.
cursor string nextCursor of the previous page.
limit integer Page size, 1 to 200. Default 50.

What is searched: the subject, the sender name and address, the preview snippet and the recipient addresses. The full body is not searched. Results are ranked subject match first, then sender, then snippet, then recipients; ties are broken by date, most recent first.

curl -G https://api.maylee.app/api/v1/emails/search \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  --data-urlencode 'q=invoice 2026' \
  --data-urlencode 'folderType=INBOX'

The response has the same shape as GET /emails: items, nextCursor, hasMore. The search cursor has its own internal format; pass it back as is.

Status Code When
400 VALIDATION_FAILED q missing, shorter than 2 or longer than 200 characters, or unknown folderType.

Unlike ?search= on GET /emails, a term that is too short is rejected here rather than ignored: returning the whole mailbox would look like a successful search.

GET /emails/{emailId}

Metadata of one email, without its body. Scope read. Cost 1.

curl https://api.maylee.app/api/v1/emails/cmemx1a2b3c4d5e6f7g8h9i0 \
  -H "Authorization: Bearer $MAYLEE_KEY"
{
	"email": {
		"id": "cmemx1a2b3c4d5e6f7g8h9i0",
		"mailboxId": "cmbx1a2b3c4d5e6f7g8h9i0j",
		"folderId": "cmfd1a2b3c4d5e6f7g8h9i0j",
		"messageId": "<CAF3k9x1@mail.example.com>",
		"threadId": "18c2f5a9e7b1d3c4",
		"subject": "Quote request",
		"from": { "email": "client@acme.com", "name": "Jane Client" },
		"to": [{ "email": "contact@your-company.com", "name": null }],
		"cc": [],
		"bcc": [],
		"date": "2026-08-25T13:47:02.000Z",
		"snippet": "Hello, could you send us a quote for…",
		"isRead": false,
		"isStarred": false,
		"isDraft": false,
		"hasAttachments": true,
		"attachments": [
			{
				"filename": "brief.pdf",
				"size": 48213,
				"mimeType": "application/pdf"
			}
		],
		"inReplyTo": null,
		"references": [],
		"labels": ["INBOX", "IMPORTANT"],
		"waitingForReply": true,
		"isAutoReply": false,
		"isAutoForward": false,
		"scheduledStatus": null,
		"scheduledSendAt": null
	}
}

This is the object you need to reply in a thread: messageId, references, from and mailboxId. See Email for every field.

Status Code When
404 EMAIL_NOT_FOUND Unknown id, or the email belongs to another workspace.

GET /emails/{emailId}/body

Body of an email and the whole conversation it belongs to. Scope read. Cost 1.

curl https://api.maylee.app/api/v1/emails/cmemx1a2b3c4d5e6f7g8h9i0/body \
  -H "Authorization: Bearer $MAYLEE_KEY"
{
	"emailId": "cmemx1a2b3c4d5e6f7g8h9i0",
	"html": "<p>Hello, could you send us a quote for…</p>",
	"text": "Hello, could you send us a quote for…",
	"thread": [
		{
			"id": "cmemw0z9y8x7w6v5u4t3s2r1",
			"messageId": "<20260820091500.9f8e7d@your-company.com>",
			"references": [],
			"fromAddress": "contact@your-company.com",
			"fromName": "Contact",
			"toAddresses": [
				{ "email": "client@acme.com", "name": "Jane Client" }
			],
			"date": "2026-08-20T09:15:00.000Z",
			"subject": "Introduction",
			"html": "<p>Hi Jane, nice meeting you.</p>",
			"text": "Hi Jane, nice meeting you.",
			"isMe": true
		},
		{
			"id": "cmemx1a2b3c4d5e6f7g8h9i0",
			"messageId": "<CAF3k9x1@mail.example.com>",
			"references": ["<20260820091500.9f8e7d@your-company.com>"],
			"fromAddress": "client@acme.com",
			"fromName": "Jane Client",
			"toAddresses": [{ "email": "contact@your-company.com" }],
			"date": "2026-08-25T13:47:02.000Z",
			"subject": "Quote request",
			"html": "<p>Hello, could you send us a quote for…</p>",
			"text": "Hello, could you send us a quote for…",
			"isMe": false
		}
	]
}

html and text belong to the requested email; either may be absent when the message has no body of that kind. thread lists every message of the conversation oldest first, resolved from the provider thread id when there is one, and from the Message-ID, In-Reply-To and References headers otherwise. It includes messages you sent (isMe: true) and any draft in progress in that conversation, and is bounded to the mailbox the email lives in. Inline images referenced by the HTML are resolved for you.

Each entry is a ThreadMessage. It works with a draft id too.

Status Code When
404 NOT_FOUND Unknown id, or the email belongs to another workspace.

PATCH /emails/{emailId}

Mark an email as read or unread, starred or unstarred. Scope write. Cost 1.

Body field Type Description
isRead boolean Optional.
isStarred boolean Optional.

At least one of the two must be present. A body with neither is rejected rather than treated as a silent no-op, so that a misspelt field name is noticed.

curl -X PATCH https://api.maylee.app/api/v1/emails/cmemx1a2b3c4d5e6f7g8h9i0 \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"isRead": true, "isStarred": true}'

Returns { "email": … }, the updated Email.

The change is idempotent (an email already in the target state is left untouched), pushed to open Maylee windows in real time, propagated to the provider (Gmail, Microsoft, IMAP) in the background, and delivered to email.updated webhooks with the fields that changed.

Status Code When
400 VALIDATION_FAILED Neither isRead nor isStarred is a boolean. details.accepted lists the accepted fields.
404 NOT_FOUND Unknown id, or the email belongs to another workspace.

DELETE /emails/{emailId}

Move an email to the trash. Scope write. Cost 1.

curl -X DELETE https://api.maylee.app/api/v1/emails/cmemx1a2b3c4d5e6f7g8h9i0 \
  -H "Authorization: Bearer $MAYLEE_KEY"

Returns 204 No Content.

Never a permanent deletion. The message stays recoverable from the trash, in Maylee and at the provider. An email that is already in the trash returns 204 as well: replaying a deletion is not an error. The deletion is pushed in real time and delivered to email.deleted webhooks.

Status Code When
400 NO_TRASH_FOLDER The mailbox has no trash folder, so the move cannot be applied.
404 NOT_FOUND Unknown id, or the email belongs to another workspace.

POST /emails/{emailId}/labels/{labelId}

Apply a label to an email. Scope write. Cost 1.

curl -X POST https://api.maylee.app/api/v1/emails/cmemx1a2b3c4d5e6f7g8h9i0/labels/cmtg1a2b3c4d5e6f7g8h9i0j \
  -H "Authorization: Bearer $MAYLEE_KEY"
{
	"labels": [
		{
			"id": "cmtg1a2b3c4d5e6f7g8h9i0j",
			"name": "Quotes",
			"color": "#16a34a",
			"isAiLabel": false
		},
		{
			"id": "cmtgai1b2c3d4e5f6g7h8i9j",
			"name": "Sales",
			"color": "#2563eb",
			"isAiLabel": true
		}
	]
}

Returns every label now on the email. Idempotent: applying a label the email already carries returns the same list, and does not fire the label.applied webhook a second time. A real application is pushed to open Maylee windows and delivered to label.applied webhooks. Both manual and AI labels can be applied this way; get ids from GET /labels.

Status Code When
404 EMAIL_NOT_FOUND Unknown email, or it belongs to another workspace.
404 LABEL_NOT_FOUND Unknown label, or it belongs to another workspace.

DELETE /emails/{emailId}/labels/{labelId}

Remove a label from an email. Scope write. Cost 1.

curl -X DELETE https://api.maylee.app/api/v1/emails/cmemx1a2b3c4d5e6f7g8h9i0/labels/cmtg1a2b3c4d5e6f7g8h9i0j \
  -H "Authorization: Bearer $MAYLEE_KEY"

Returns 204 No Content. Idempotent: removing a label the email does not have is not an error.

Status Code When
404 EMAIL_NOT_FOUND Unknown email, or it belongs to another workspace.

POST /emails/bulk

Mark as read, move to trash, apply or remove a label, on up to 500 emails in one call. Scope write. Cost: one unit per email in emailIds.

Body field Type Description
action string Required. markRead, delete, applyLabel or removeLabel.
emailIds string[] Required. 1 to 500 ids. Duplicates are ignored.
isRead boolean Required when action is markRead.
labelId string Required when action is applyLabel or removeLabel.

Supports Idempotency-Key.

curl -X POST https://api.maylee.app/api/v1/emails/bulk \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "action": "markRead",
    "isRead": true,
    "emailIds": ["cmemx1a2b3c4d5e6f7g8h9i0", "cmemy9z8y7x6w5v4u3t2s1r0", "cmemz0000000000000000000"]
  }'

Response for markRead:

{
	"updatedIds": ["cmemx1a2b3c4d5e6f7g8h9i0"],
	"unchangedIds": ["cmemy9z8y7x6w5v4u3t2s1r0"],
	"skippedIds": ["cmemz0000000000000000000"],
	"isRead": true
}

Response for delete:

{
	"deletedIds": ["cmemx1a2b3c4d5e6f7g8h9i0", "cmemy9z8y7x6w5v4u3t2s1r0"],
	"skippedIds": ["cmemz0000000000000000000"],
	"noTrashIds": []
}

Response for applyLabel and removeLabel:

{
	"labelId": "cmtg1a2b3c4d5e6f7g8h9i0j",
	"emailIds": ["cmemx1a2b3c4d5e6f7g8h9i0", "cmemy9z8y7x6w5v4u3t2s1r0"],
	"skippedIds": ["cmemz0000000000000000000"],
	"updatedCount": 1
}

emailIds lists the emails of the workspace the action was applied to, updatedCount how many actually changed (an email that already carried the label, or did not carry it on removal, counts as processed but not changed). label.applied webhooks are delivered for the processed emails when at least one changed.

Partial failures do not fail the batch: ids that are unknown, that belong to another workspace, or that are already in the target state come back in skippedIds (or unchangedIds for read state) rather than aborting the operation. noTrashIds lists emails whose mailbox has no trash folder.

Status Code When
400 VALIDATION_FAILED Empty or missing emailIds, more than 500 ids, unknown action, isRead missing for markRead, or labelId missing for applyLabel / removeLabel.
404 LABEL_NOT_FOUND labelId is unknown, or belongs to another workspace.
409 CONFLICT Idempotency-Key reused with a different body, or the original call is still in progress.

Drafts

GET /drafts

The most recent drafts of the workspace. Scope read. Cost 1.

Query parameter Type Description
limit integer 1 to 200. Default 50.
curl -G https://api.maylee.app/api/v1/drafts \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  --data-urlencode 'limit=20'

Returns { "items": [] } of Email objects with isDraft: true, most recent first. This list is not paginated: there is no nextCursor, and at most limit drafts are returned. The body of a draft is fetched with GET /emails/{draftId}/body.

GET /drafts/{draftId}

One draft. Scope read. Cost 1.

curl https://api.maylee.app/api/v1/drafts/cmdr1a2b3c4d5e6f7g8h9i0j \
  -H "Authorization: Bearer $MAYLEE_KEY"

Returns { "draft": … }, an Email with isDraft: true. Scheduled drafts carry scheduledStatus and scheduledSendAt.

Status Code When
404 DRAFT_NOT_FOUND Unknown id, not a draft, or it belongs to another workspace.

POST /drafts

Create a draft in one of the workspace mailboxes, without sending it. Scope write. Cost 1.

This is the way to prepare an email from your code or from an agent while keeping a human in the loop: the draft shows up immediately in the Drafts folder of the Maylee app, where the user reviews it, edits it if needed, and sends it. Nothing leaves the workspace, so the outbound sending option is not required: a key that cannot send can still prepare drafts.

Body field Type Description
mailboxId string Required. The mailbox the draft belongs to, and it will be sent from.
to EmailAddress[] Optional, defaults to none. The user can add recipients in the app before sending.
cc EmailAddress[] Optional.
bcc EmailAddress[] Optional.
subject string Optional, defaults to an empty subject. No line breaks.
body object Required. { "html": "…" }, { "text": "…" } or both. text feeds the preview snippet.
inReplyTo string Optional. messageId of the email being answered: the draft joins that conversation.
references string[] Optional. The answered email's references followed by its messageId.
signature string Optional. "default" (the default), "none", or a signature id from GET /signatures.

Signatures work exactly as on POST /drafts/send, with one difference: a draft carries no send-as alias, so the scope is resolved on the mailbox address.

Attachments cannot be added through the API.

curl -X POST https://api.maylee.app/api/v1/drafts \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "mailboxId": "cmbx1a2b3c4d5e6f7g8h9i0j",
    "to": [{ "email": "client@acme.com", "name": "Jane Client" }],
    "subject": "Re: Quote request",
    "inReplyTo": "<CAF3k9x1@mail.example.com>",
    "references": ["<CAF3k9x1@mail.example.com>"],
    "body": {
      "text": "Hello Jane,\n\nHere is the quote you asked for.",
      "html": "<p>Hello Jane,</p><p>Here is the quote you asked for.</p>"
    }
  }'
{
	"draft": {
		"id": "cmdr1a2b3c4d5e6f7g8h9i0j",
		"mailboxId": "cmbx1a2b3c4d5e6f7g8h9i0j",
		"subject": "Re: Quote request",
		"from": { "email": "you@your-company.com", "name": null },
		"to": [{ "email": "client@acme.com", "name": "Jane Client" }],
		"isDraft": true,
		"inReplyTo": "<CAF3k9x1@mail.example.com>",
		"…": "…"
	}
}

201 Created. draft is an Email with isDraft: true, the same object GET /drafts/{draftId} returns; from is the address of the mailbox. Read it back with GET /drafts/{draftId}, and its body with GET /emails/{draftId}/body. When the user sends it from the app, the usual draft.sent webhook is delivered.

Send an Idempotency-Key: a retry after a network timeout would otherwise leave two identical drafts for the user to sort out.

Status Code When
400 VALIDATION_FAILED Missing or invalid field. details maps each field to its errors.
404 MAILBOX_NOT_FOUND Unknown mailboxId. Call GET /mailboxes for valid ids.
409 CONFLICT Idempotency-Key reused with a different body.

POST /drafts/send

Send an email immediately from one of the workspace mailboxes. Scope write and the outbound sending option on the key. Cost 1.

This is the only irreversible action of the API: the message leaves the system and reaches a third party. Always send an Idempotency-Key.

Body field Type Description
mailboxId string Required. The sending mailbox.
to EmailAddress[] Required. At least one recipient.
cc EmailAddress[] Optional.
bcc EmailAddress[] Optional.
from EmailAddress Optional send-as alias. Must be a verified alias of the mailbox; otherwise the send is refused rather than silently rewritten by the provider.
subject string Optional, defaults to an empty subject. No line breaks.
body object Required. { "html": "…" }, { "text": "…" } or both.
inReplyTo string Optional. messageId of the email being answered.
references string[] Optional. The answered email's references followed by its messageId.
signature string Optional. "default" (the default), "none", or a signature id from GET /signatures.

An EmailAddress is { "email": "…", "name": "…" }, name optional.

Signatures are applied by default. Say nothing and the email is signed the way it would be from the app: the default signature of the sending identity — the alias if from is set, otherwise the mailbox. Pass "none" to send bare, or an id to pick another. An unknown id is refused with SIGNATURE_NOT_FOUND rather than sent unsigned.

A mailbox with no signature of its own stays unsigned: the API never borrows one from another address.

Your html is sent exactly as you wrote it. The signature goes right after your message and before any quoted history, as in every mail client. If you also provide text, the plain-text part is signed too.

An explicit request always wins. Ask for a signature by id and that one is used, in place of whatever signature the body carried. Ask for "none" and the body is sent unsigned — a signature it already carried is removed. A request is never silently dropped.

"none" is remembered. A draft created with it reopens in the Maylee app without a signature, and stays that way: the composer does not add the default back when the user opens it.

Without a request, a body is never signed twice. If what you send is already signed — a draft you read back, a body you built from an earlier message — it is left alone. Two things count as already signed: a signature envelope written by Maylee, or a message that ends with the text of one of the workspace signatures. A signature that only appears inside the quoted history does not count: your new reply is still signed. Merely mentioning a word that happens to be a signature does not count either — it has to close the message.

curl -X POST https://api.maylee.app/api/v1/drafts/send \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "mailboxId": "cmbx1a2b3c4d5e6f7g8h9i0j",
    "to": [{ "email": "client@acme.com", "name": "Jane Client" }],
    "cc": [{ "email": "sales@your-company.com" }],
    "subject": "Re: Quote request",
    "inReplyTo": "<CAF3k9x1@mail.example.com>",
    "references": ["<CAF3k9x1@mail.example.com>"],
    "body": {
      "text": "Hello Jane,\n\nHere is the quote you asked for.",
      "html": "<p>Hello Jane,</p><p>Here is the quote you asked for.</p>"
    }
  }'
{
	"messageId": "<20260825140233.1a2b3c@your-company.com>",
	"threadId": "18c2f5a9e7b1d3c4"
}

201 Created. messageId is the RFC Message-ID of the sent message. threadId is the provider's conversation id, or null when the provider does not expose one. The sent message shows up in the SENT folder after the next synchronisation, and draft.sent webhooks are delivered.

Status Code When
400 VALIDATION_FAILED Missing or invalid field. details maps each field to its errors.
400 INVALID_SENDER_ALIAS from is not a verified send-as address of the mailbox.
400 MAILBOX_INACTIVE The mailbox is disconnected. Reconnect it in Maylee first.
403 OUTBOUND_SEND_NOT_ALLOWED The key has write but not the outbound sending option.
404 MAILBOX_NOT_FOUND Unknown mailboxId. Call GET /mailboxes for valid ids.
409 CONFLICT Idempotency-Key reused with a different body, or the original call is still in progress.
500 INTERNAL_ERROR The provider refused the message. Nothing was sent; the idempotency key is released so that you can retry.

Attachments cannot be sent through the API yet.

Mailboxes

Connected mailboxes are read-only through the API: connecting one goes through OAuth or credentials in the Maylee app, never through a key. The list is short by nature and returned in full, without pagination.

GET /mailboxes

Connected mailboxes of the workspace. Scope read. Cost 1.

curl https://api.maylee.app/api/v1/mailboxes \
  -H "Authorization: Bearer $MAYLEE_KEY"
{
	"items": [
		{
			"id": "cmbx1a2b3c4d5e6f7g8h9i0j",
			"email": "contact@your-company.com",
			"name": "Contact",
			"picture": null,
			"provider": "GOOGLE",
			"isActive": true,
			"createdAt": "2026-06-02T09:14:31.000Z",
			"lastSyncAt": "2026-08-25T14:02:11.000Z",
			"syncError": null,
			"backwardSyncComplete": true
		}
	]
}

See Mailbox. Credentials and OAuth tokens are never exposed.

Signatures

Signatures are read-only through the API: they are written in a rich editor in the Maylee app, images included. What the API gives you is the id behind a name, so a send can ask for one.

You never handle the HTML yourself — the server assembles the body, exactly as the composer does, and the result carries the same zone markers. A draft created through the API therefore reopens in the app as three clean zones, its signature still replaceable.

GET /signatures

Signatures of the workspace. Scope read. Cost 1.

curl https://api.maylee.app/api/v1/signatures \
  -H "Authorization: Bearer $MAYLEE_KEY"
{
	"items": [
		{
			"id": "csig1a2b3c4d5e6f7g8h9i0j",
			"name": "Sales",
			"mailboxId": "cmbx1a2b3c4d5e6f7g8h9i0j",
			"aliasEmail": null,
			"isDefault": true,
			"position": 0,
			"createdAt": "2026-06-02T09:14:31.000Z",
			"updatedAt": "2026-09-18T11:40:02.000Z"
		}
	]
}

See Signature. The content is never returned: it can reach 128 KB, and you have no reason to carry it around.

mailboxId and aliasEmail describe the scope. Both null means the signature is available from any address of the workspace. A signature bound to a mailbox is never offered from another one — the API applies the same rule as the app.

Labels

Two kinds of labels live in a workspace. Manual labels are created here and applied by people, or by you through the API. AI labels are created through an AI label rule and applied automatically by Maylee's classification pipeline; they show up in this list with isAiLabel: true, and are renamed or deleted through their rule, never here.

Lists are short by nature and returned in full, without pagination.

GET /labels

Every label of the workspace, AI labels included. Scope read. Cost 1.

curl https://api.maylee.app/api/v1/labels \
  -H "Authorization: Bearer $MAYLEE_KEY"
{
	"items": [
		{
			"id": "cmtg1a2b3c4d5e6f7g8h9i0j",
			"name": "Quotes",
			"color": "#16a34a",
			"isAiLabel": false,
			"createdAt": "2026-06-02T09:20:00.000Z"
		},
		{
			"id": "cmtgai1b2c3d4e5f6g7h8i9j",
			"name": "Sales",
			"color": "#2563eb",
			"isAiLabel": true,
			"createdAt": "2026-06-03T08:00:00.000Z"
		}
	]
}

Use the ids in the label and ai_label filters, and on POST /emails/{emailId}/labels/{labelId}.

POST /labels

Create a manual label. Scope write. Cost 1. Supports Idempotency-Key.

Body field Type Description
name string Required. 1 to 50 characters. Unique in the workspace.
color string Required. A hex color such as #16a34a.
curl -X POST https://api.maylee.app/api/v1/labels \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "name": "Invoices", "color": "#f59e0b" }'
{
	"label": {
		"id": "cmtg2b3c4d5e6f7g8h9i0j1k",
		"name": "Invoices",
		"color": "#f59e0b",
		"isAiLabel": false,
		"createdAt": "2026-09-14T09:12:00.000Z"
	}
}

201 Created. To have Maylee apply a label automatically, create an AI label rule instead.

Status Code When
400 VALIDATION_FAILED Missing or invalid field.
409 DUPLICATE_TAG_NAME A label with this name already exists in the workspace.

GET /labels/{labelId}

One label, manual or AI. Scope read. Cost 1. Returns { "label": … }.

Status Code When
404 LABEL_NOT_FOUND Unknown id, or the label belongs to another workspace.

PATCH /labels/{labelId}

Rename or recolor a manual label. Scope write. Cost 1.

Body field Type Description
name string Optional. 1 to 50 characters.
color string Optional.

At least one field is required. Returns { "label": … }.

Status Code When
400 VALIDATION_FAILED Invalid field, or no field at all. details.accepted lists the accepted fields.
400 INVALID_TAG The label is an AI label: change it through PATCH /ai-label-rules/{ruleId}.
404 LABEL_NOT_FOUND Unknown id, or the label belongs to another workspace.
409 DUPLICATE_TAG_NAME A label with this name already exists.

DELETE /labels/{labelId}

Delete a manual label and remove it from every email that carries it. Scope write. Cost 1. Returns 204 No Content.

Status Code When
400 INVALID_TAG The label is an AI label: delete it through DELETE /ai-label-rules/{ruleId}.
404 LABEL_NOT_FOUND Unknown id, or the label belongs to another workspace.

AI label rules

An AI label rule is a label plus a condition written in plain language. Maylee reads each incoming email, and applies the label when the condition holds. A rule can also turn on auto-draft (Maylee prepares a reply you review) and auto-reply (Maylee sends the reply itself, after an optional delay, when its confidence is above the threshold).

Auto-reply makes Maylee send email on your behalf. Setting autoReplyEnabled: true, on creation or on update, requires the outbound sending option on the key, exactly like POST /drafts/send. Every other change works with write alone.

GET /ai-label-rules

Every rule of the workspace, with its label. Scope read. Cost 1.

curl https://api.maylee.app/api/v1/ai-label-rules \
  -H "Authorization: Bearer $MAYLEE_KEY"
{
	"items": [
		{
			"id": "cmrl1a2b3c4d5e6f7g8h9i0j",
			"label": {
				"id": "cmtgai1b2c3d4e5f6g7h8i9j",
				"name": "Sales",
				"color": "#2563eb",
				"isAiLabel": true
			},
			"condition": "The sender asks about pricing, a quote or a demo of our product.",
			"autoDraftEnabled": true,
			"autoReplyEnabled": false,
			"autoReplyConfidenceThreshold": 85,
			"autoReplyDelayMinutes": 0,
			"autoReplyNotifyOnSend": true,
			"createdAt": "2026-06-03T08:00:00.000Z",
			"updatedAt": "2026-08-20T16:40:00.000Z"
		}
	]
}

POST /ai-label-rules

Create the label and its rule in one call. Scope write. Cost 1. Supports Idempotency-Key.

Body field Type Description
name string Required. Label name, 1 to 50 characters, unique in the workspace.
color string Required. Hex color.
condition string Required. Plain-language condition, 1 to 500 characters. Write it the way you would brief a colleague: what the email is about, who sends it, what it asks for.
autoDraftEnabled boolean Optional, default false. Maylee prepares a reply for you to review.
autoReplyEnabled boolean Optional, default false. Maylee sends the reply itself. Requires outbound sending on the key.
autoReplyConfidenceThreshold integer Optional, 0 to 100, default 85. Below it, the reply is drafted, not sent.
autoReplyDelayMinutes integer Optional, 0 to 15, default 0. Wait before sending, so that you can cancel from the app.
autoReplyNotifyOnSend boolean Optional, default true.
hideFromInbox boolean Optional, default false. Emails that receive this label leave the main inbox: they stay in INBOX, are listed by GET /emails?filtered=only, and remain visible in any view whose criteria match them.
curl -X POST https://api.maylee.app/api/v1/ai-label-rules \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "name": "Invoices",
    "color": "#f59e0b",
    "condition": "The email is an invoice, a receipt or a payment confirmation from a supplier or a SaaS we pay."
  }'

201 Created, with { "rule": … } as in the list above. The rule applies to emails received from now on. To classify the emails already in the inbox, run POST /ai-label-rules/backfill.

Status Code When
400 VALIDATION_FAILED Missing or invalid field, empty condition, threshold or delay out of range.
403 OUTBOUND_SEND_NOT_ALLOWED autoReplyEnabled: true on a key without outbound sending.
409 DUPLICATE_RULE An AI label with this name already exists.

POST /ai-label-rules/backfill

Classify the emails already in the inbox with the rules of the workspace. Scope write. Cost 1.

A rule only applies to emails synchronized after its creation. This endpoint queues one background job per active mailbox (or the one you name) that runs every rule of the workspace on the limit most recent INBOX emails of that mailbox. The call returns immediately with 202 Accepted; labels land over the following minutes, one label.applied webhook event each.

A backfill applies labels and nothing else: no auto-forward, auto-draft or auto-reply is triggered by it. Each classified email costs one call to the model, so keep limit to what you need.

Body field Type Description
mailboxId string Optional. Restrict to one mailbox. Omitted, every active mailbox is queued.
limit integer Optional, 1 to 500, default 200. Most recent INBOX emails to classify, per mailbox.
curl -X POST https://api.maylee.app/api/v1/ai-label-rules/backfill \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "limit": 300 }'
{
	"jobs": 2,
	"mailboxIds": ["cmbx1a2b3c4d5e6f7g8h9i0j", "cmbx2b3c4d5e6f7g8h9i0j1k"]
}
Status Code When
400 VALIDATION_FAILED limit out of range, or the workspace has no AI label rule yet.
404 MAILBOX_NOT_FOUND mailboxId is unknown, inactive, or belongs to another workspace.

GET /ai-label-rules/{ruleId}

One rule. Scope read. Cost 1. Returns { "rule": … }.

Status Code When
404 RULE_NOT_FOUND Unknown id, or the rule belongs to another workspace.

PATCH /ai-label-rules/{ruleId}

Rename or recolor the label, change the condition, configure auto-draft and auto-reply, or toggle hideFromInbox. Scope write. Cost 1. Same fields as creation, all optional; at least one is required. Returns { "rule": … }.

curl -X PATCH https://api.maylee.app/api/v1/ai-label-rules/cmrl1a2b3c4d5e6f7g8h9i0j \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "autoDraftEnabled": true }'
Status Code When
400 VALIDATION_FAILED Invalid field, or no field at all. details.accepted lists the accepted fields.
403 OUTBOUND_SEND_NOT_ALLOWED autoReplyEnabled: true on a key without outbound sending.
404 RULE_NOT_FOUND Unknown id, or the rule belongs to another workspace.
409 DUPLICATE_RULE An AI label with the new name already exists.

DELETE /ai-label-rules/{ruleId}

Delete the rule and its label, and remove the label from every email that carried it. Scope write. Cost 1. Returns 204 No Content.

Status Code When
404 RULE_NOT_FOUND Unknown id, or the rule belongs to another workspace.

Views

A view is a filter tree saved under a name, applied with ?viewId= on GET /emails. Its filters use the same DSL as ?filters=, and are bounded the same way: 5 levels, 50 conditions, 500-character values.

GET /views

Saved views of the workspace, in the order they are shown in the app. Scope read. Cost 1.

curl https://api.maylee.app/api/v1/views \
  -H "Authorization: Bearer $MAYLEE_KEY"
{
	"items": [
		{
			"id": "cmvw1a2b3c4d5e6f7g8h9i0j",
			"name": "Hot leads",
			"icon": "🔥",
			"filters": {
				"id": "g1",
				"_and": [
					{
						"id": "c1",
						"label": "ai_label",
						"type": "contains",
						"value": "cmtgai1b2c3d4e5f6g7h8i9j"
					},
					{
						"id": "c2",
						"label": "read_status",
						"type": "is_false",
						"value": ""
					}
				]
			},
			"mailboxIds": ["cmbx1a2b3c4d5e6f7g8h9i0j"],
			"createdAt": "2026-06-10T10:00:00.000Z"
		}
	]
}

POST /views

Create a view. Scope write. Cost 1. Supports Idempotency-Key.

Body field Type Description
name string Required. 1 to 100 characters.
icon string or null Optional. An emoji, no markup.
filters object Optional. A filter tree, or {} for a view without filter (the default).
curl -X POST https://api.maylee.app/api/v1/views \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "name": "Invoices this month",
    "icon": "🧾",
    "filters": {
      "id": "g1",
      "_and": [
        { "id": "c1", "label": "label", "type": "contains", "value": "cmtg2b3c4d5e6f7g8h9i0j1k" },
        { "id": "c2", "label": "date", "type": "after", "value": "2026-09-01T00:00:00Z" }
      ]
    }
  }'

201 Created, with { "view": … }. Restricting a view to specific mailboxes (mailboxIds) is done from the app.

Status Code When
400 VALIDATION_FAILED Missing or invalid field, invalid filter tree, or a bound was exceeded. details names the field or the bound.

GET /views/{viewId}

One view. Scope read. Cost 1. Returns { "view": … }.

Status Code When
404 VIEW_NOT_FOUND Unknown id, or the view belongs to another workspace.

PATCH /views/{viewId}

Update a view. Scope write. Cost 1. Same fields as creation, all optional; at least one is required. filters replaces the whole tree. Returns { "view": … }.

Status Code When
400 VALIDATION_FAILED Invalid field, no field at all, invalid filter tree, or a bound was exceeded.
404 VIEW_NOT_FOUND Unknown id, or the view belongs to another workspace.

DELETE /views/{viewId}

Delete a view. Scope write. Cost 1. Returns 204 No Content. Emails are not affected.

Status Code When
404 VIEW_NOT_FOUND Unknown id, or the view belongs to another workspace.

Inbox filters

A filter hides matching emails from the main inbox without moving them: they stay in the INBOX folder, are listed by GET /emails?filtered=only (the app's "Filtered emails"), and remain visible in every saved view whose criteria match them. Filters are evaluated when listing, so a filter created today also hides yesterday's emails, and deleting it brings everything back.

An AI label rule with hideFromInbox: true hides emails the same way, the moment the label is applied.

type value
sender An email address, matched exactly, case-insensitively. Stored lowercase.
domain A domain such as acme.com, matched against the sender's domain. A leading @ is dropped.
subject A text contained in the subject, case-insensitively.
label The id of a label (manual or AI) carried by the email. Must exist in the workspace.

Lists are short by nature and returned in full, without pagination.

GET /filters

Every filter of the workspace. Scope read. Cost 1.

curl https://api.maylee.app/api/v1/filters \
  -H "Authorization: Bearer $MAYLEE_KEY"
{
	"items": [
		{
			"id": "cmfl1a2b3c4d5e6f7g8h9i0j",
			"type": "domain",
			"value": "newsletter.io",
			"label": null,
			"createdAt": "2026-09-14T10:00:00.000Z",
			"updatedAt": "2026-09-14T10:00:00.000Z"
		},
		{
			"id": "cmfl2b3c4d5e6f7g8h9i0j1k",
			"type": "label",
			"value": "cmtgai1b2c3d4e5f6g7h8i9j",
			"label": {
				"id": "cmtgai1b2c3d4e5f6g7h8i9j",
				"name": "Newsletter",
				"color": "#eab308",
				"isAiLabel": true
			},
			"createdAt": "2026-09-14T10:05:00.000Z",
			"updatedAt": "2026-09-14T10:05:00.000Z"
		}
	]
}

POST /filters

Create a filter. Scope write. Cost 1. Supports Idempotency-Key.

Body field Type Description
type string Required. sender, domain, subject or label.
value string Required. 1 to 255 characters, validated according to type.
curl -X POST https://api.maylee.app/api/v1/filters \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "type": "domain", "value": "newsletter.io" }'
{
	"filter": {
		"id": "cmfl1a2b3c4d5e6f7g8h9i0j",
		"type": "domain",
		"value": "newsletter.io",
		"label": null,
		"createdAt": "2026-09-14T10:00:00.000Z",
		"updatedAt": "2026-09-14T10:00:00.000Z"
	}
}

201 Created. The emails it matches disappear from the main inbox at the next listing.

Status Code When
400 VALIDATION_FAILED Unknown type, or a value that is not an address, a domain or an id.
404 LABEL_NOT_FOUND type: "label" with an id that is not a label of the workspace.
409 DUPLICATE_FILTER A filter with this type and value already exists. details.filterId names it.

GET /filters/{filterId}

One filter. Scope read. Cost 1. Returns { "filter": … }.

Status Code When
404 FILTER_NOT_FOUND Unknown id, or the filter belongs to another workspace.

PATCH /filters/{filterId}

Change type and/or value. Scope write. Cost 1. The resulting filter is validated as a whole: changing type to sender while keeping a domain as value is refused. Returns { "filter": … }.

curl -X PATCH https://api.maylee.app/api/v1/filters/cmfl1a2b3c4d5e6f7g8h9i0j \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "value": "news.acme.com" }'
Status Code When
400 VALIDATION_FAILED Invalid field, incompatible type and value, or no field at all.
404 FILTER_NOT_FOUND Unknown id, or the filter belongs to another workspace.
409 DUPLICATE_FILTER A filter with this type and value already exists.

DELETE /filters/{filterId}

Delete a filter. Scope write. Cost 1. Returns 204 No Content. The emails it hid reappear in the main inbox immediately.

Status Code When
404 FILTER_NOT_FOUND Unknown id, or the filter belongs to another workspace.

Auto-forward rules

An auto-forward rule forwards every incoming email that matches a condition to an address. A rule makes email leave the workspace, so creating, changing or deleting one requires the outbound sending option on the key, exactly like sending an email. A key with write alone gets 403 OUTBOUND_SEND_NOT_ALLOWED. Reading rules and their logs only needs read.

GET /auto-forward-rules

Every rule of the workspace. Scope read. Cost 1.

{
	"items": [
		{
			"id": "cmfw1a2b3c4d5e6f7g8h9i0j",
			"conditionField": "label",
			"conditionValue": "Invoices",
			"forwardTo": "invoices@your-accounting-tool.com",
			"mode": "normal",
			"isActive": true,
			"createdAt": "2026-09-14T09:15:00.000Z",
			"updatedAt": "2026-09-14T09:15:00.000Z"
		}
	]
}

POST /auto-forward-rules

Create a rule. Scope write and outbound sending. Cost 1. Supports Idempotency-Key.

Body field Type Description
conditionField string Optional, default label. One of label, sender_contains, sender_equals, subject_contains, subject_equals.
conditionValue string Required. With label: the name of a label (manual or AI). Otherwise the text to match, case-insensitively, against the sender (address and name) or the subject.
forwardTo string Required. The destination email address.
mode string Optional, default normal. normal forwards with a Fwd: subject and the usual quoted header. invisible sends the original message as is, with Reply-To set to the original sender, so that the recipient replies to them directly.
isActive boolean Optional, default true.
curl -X POST https://api.maylee.app/api/v1/auto-forward-rules \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "conditionField": "label",
    "conditionValue": "Invoices",
    "forwardTo": "invoices@your-accounting-tool.com"
  }'

201 Created, with { "rule": … }. Combined with an AI label rule, this is how you route every invoice to your accounting tool without touching them: the AI applies the label, the rule forwards.

Status Code When
400 VALIDATION_FAILED forwardTo is not an email address, conditionValue is empty, or an unknown conditionField / mode.
403 OUTBOUND_SEND_NOT_ALLOWED The key has write but not the outbound sending option.

GET /auto-forward-rules/{ruleId}

One rule. Scope read. Cost 1. Returns { "rule": … }.

Status Code When
404 RULE_NOT_FOUND Unknown id, or the rule belongs to another workspace.

PATCH /auto-forward-rules/{ruleId}

Update a rule. Scope write and outbound sending. Cost 1. Same fields as creation, all optional; at least one is required, and forwardTo or conditionValue cannot be emptied. Returns { "rule": … }.

Status Code When
400 VALIDATION_FAILED Invalid field, no field at all, or an emptied forwardTo / conditionValue.
403 OUTBOUND_SEND_NOT_ALLOWED The key has write but not the outbound sending option.
404 RULE_NOT_FOUND Unknown id, or the rule belongs to another workspace.

DELETE /auto-forward-rules/{ruleId}

Delete a rule. Scope write and outbound sending. Cost 1. Returns 204 No Content. Emails already forwarded are not affected.

Status Code When
403 OUTBOUND_SEND_NOT_ALLOWED The key has write but not the outbound sending option.
404 RULE_NOT_FOUND Unknown id, or the rule belongs to another workspace.

GET /auto-forward-rules/{ruleId}/logs

The 50 most recent forwarding attempts of a rule, newest first, with the email that triggered each one. Scope read. Cost 1.

{
	"items": [
		{
			"id": "cmlg1a2b3c4d5e6f7g8h9i0j",
			"emailId": "cmemx1a2b3c4d5e6f7g8h9i0",
			"destination": "invoices@your-accounting-tool.com",
			"mode": "normal",
			"status": "sent",
			"error": null,
			"forwardedMessageId": "<20260914091702.7c1d2e@your-company.com>",
			"createdAt": "2026-09-14T09:17:02.000Z",
			"email": {
				"id": "cmemx1a2b3c4d5e6f7g8h9i0",
				"subject": "Invoice #2026-0912",
				"from": {
					"email": "billing@saas.example",
					"name": "SaaS Billing"
				},
				"date": "2026-09-14T09:16:40.000Z"
			}
		}
	]
}

status is sent or failed; on failure, error says why. Attempts still in progress are not listed.

Status Code When
404 RULE_NOT_FOUND Unknown id, or the rule belongs to another workspace.

Discovery

GET /openapi.json

The OpenAPI 3.1 specification of this API. No authentication required, so that you can generate a client, or let an agent discover the surface, before you have a key. It describes shapes only, never data. Served byte for byte identical to the committed file; keys are sorted, so a diff between two versions only shows real contract changes.

Objects

EmailListItem

Returned in items by GET /emails and GET /emails/search.

Field Type Description
id string Maylee id of the email.
mailboxId string Mailbox the email lives in.
folderId string Folder the email lives in.
messageId string RFC Message-ID header. Needed to reply.
threadId string or null Provider conversation id, when the provider has one.
subject string
from EmailAddress
to, cc EmailAddress[]
date string ISO 8601.
snippet string Short text preview.
isRead, isStarred, isDraft boolean
waitingForReply boolean Maylee's estimate that this email expects an answer you have not sent yet.
hasAttachments boolean
attachments Attachment[] Metadata only.
tags Label[] Maylee labels applied to the email: id, name, color, isAiLabel.
inReplyTo string or null RFC In-Reply-To header.
references string[] RFC References header.

Email

Returned by GET /emails/{emailId}, PATCH /emails/{emailId}, GET /drafts, GET /drafts/{draftId} and POST /drafts. Same fields as EmailListItem, except:

Field Type Description
bcc EmailAddress[] Present here, absent from list items.
labels string[] Labels as known by the provider (for example Gmail's INBOX, IMPORTANT), not Maylee labels. Maylee labels are the tags of list items and are not included on this object.
isAutoReply boolean Sent automatically by Maylee's auto-reply.
isAutoForward boolean Sent automatically by an auto-forward rule.
scheduledStatus string or null For a scheduled draft: its sending state.
scheduledSendAt string or null For a scheduled draft: when it will be sent.

There is no tags field on this object.

ThreadMessage

One message of the thread array returned by GET /emails/{emailId}/body. Its shape differs from Email: it carries the body.

Field Type Description
id string Maylee id. Use it with GET /emails/{id} for the full metadata.
messageId string RFC Message-ID.
references string[]
fromAddress, fromName string fromName may be absent.
toAddresses, ccAddresses, bccAddresses EmailAddress[] cc and bcc may be absent.
date string ISO 8601.
subject string
html, text string The body. Either may be absent.
isMe boolean true when sent from the mailbox itself.
isDraft, isAutoReply, isAutoForward boolean Present only when true.

Treat any other field on a thread message as unstable: this object is being aligned with Email and only the fields above are part of the contract.

Attachment

Field Type Description
filename string
size integer Bytes.
mimeType string Defaults to application/octet-stream when unknown.

Metadata only. Downloading attachments through the API is not available yet.

EmailAddress

Field Type Description
email string
name string or null Display name. Optional when you send.

Mailbox

Field Type Description
id string
email string Address of the mailbox.
name string or null Display name.
picture string or null Avatar URL.
provider string GOOGLE, MICROSOFT or IMAP.
isActive boolean false when disconnected; sending from it is refused.
createdAt string ISO 8601.
lastSyncAt string or null Last successful synchronisation.
syncError string or null Last synchronisation error, if any.
backwardSyncComplete boolean true once the historical import of the mailbox is finished.

Label

Field Type Description
id string
name string
color string Hex color.
isAiLabel boolean Applied by the AI pipeline; managed through its rule.
createdAt string ISO 8601. Only on the /labels endpoints; the tags of list items and the labels returned when applying a label carry the four other fields only.

AiLabelRule

Field Type Description
id string
label Label The AI label the rule applies (id, name, color, isAiLabel: true).
condition string Plain-language condition, up to 500 characters.
autoDraftEnabled boolean Maylee prepares a reply for review.
autoReplyEnabled boolean Maylee sends the reply itself. Requires outbound sending on the key to set.
autoReplyConfidenceThreshold integer 0 to 100.
autoReplyDelayMinutes integer 0 to 15.
autoReplyNotifyOnSend boolean
hideFromInbox boolean Emails that receive this label leave the main inbox.
createdAt, updatedAt string ISO 8601.

InboxFilter

Field Type Description
id string
type string sender, domain, subject or label.
value string Address (lowercase), domain (lowercase, no @), subject text, or label id.
label Label or null The targeted label when type is label.
createdAt, updatedAt string ISO 8601.

View

Field Type Description
id string
name string
icon string or null
filters object The view's filter tree in the public DSL, or {} when the view has no filter.
mailboxIds string[] Mailboxes the view is restricted to. Empty means all.
createdAt string ISO 8601.

AutoForwardRule

Field Type Description
id string
conditionField string label, sender_contains, sender_equals, subject_contains or subject_equals.
conditionValue string A label name, or the text to match.
forwardTo string Destination address.
mode string normal or invisible.
isActive boolean
createdAt, updatedAt string ISO 8601.

ForwardLog

Field Type Description
id string
emailId string The email that triggered the attempt.
destination string Address used at the time of the attempt.
mode string Mode used at the time of the attempt.
status string sent or failed.
error string or null Why it failed.
forwardedMessageId string or null RFC Message-ID of the forwarded message, on success.
createdAt string ISO 8601.
email object id, subject, from (EmailAddress), date.

Not available through the API yet

So that you do not look for them: the following are visible in responses or events but cannot be acted on through the API today.

  • Downloading or sending attachments. Only their metadata is exposed.
  • Connecting a mailbox, or restricting a view to specific mailboxes. Both are done from the app.
  • Editing or deleting drafts. Drafts can be created (POST /drafts), listed and read; changing or discarding one is done from the app. Sending with POST /drafts/send is immediate and does not create a draft.
  • Moving an email between folders, other than to the trash.
  • Permanent deletion. By design, and not planned.
  • Managing API keys and webhooks with a key. Both are created from the Maylee app, by owners and admins, so that a leaked key cannot mint new keys or redirect your events.
  • A reply helper. Replying is done with POST /drafts/send and the three threading fields, see Getting started.

Filters

GET /emails accepts a JSON filter tree through ?filters=. It lets you compose an arbitrarily precise query without Maylee having to anticipate every combination, which is what an agent needs when it builds its request at run time.

It is also the most sensitive part of the API: the tree is translated directly into a database query, and a few lines of JSON can ask for work out of all proportion with their size. Hence the bounds below.

Shape

Two kinds of nodes, and nothing else.

A condition:

{ "id": "c1", "label": "keyword_subject", "type": "contains", "value": "quote" }

A group:

{ "id": "g1", "_and": [ ] }
{ "id": "g1", "_or":  [ ] }

id is free-form: it identifies a node on your side, the server does not use it. In a condition, label names the field (the word is historical; it is not related to email labels), type names the operator and value the operand.

A node that is neither a valid condition nor a group is refused. A typo in a field name therefore does not return the whole mailbox but an error naming the faulty field, with the list of accepted values:

{
	"code": "VALIDATION_FAILED",
	"error": "Each filter node must be either a valid condition (`label`, `type`, `value`) or a group (`_and` / `_or`).",
	"details": {
		"label": ["Invalid enum value. Expected 'mailbox' | 'label' | 'ai_label' | 'keyword_subject' | 'sender_address' | 'sender_domain' | 'date' | 'read_status' | 'has_attachment', received 'nope'"]
	}
}

Fields and operators

An operator is only valid on the fields listed for it. Any other combination is refused.

Field (label) Operators (type) Expected value
mailbox is, is_not a mailbox id, from GET /mailboxes
label contains, not_contains a label id, from GET /labels
ai_label contains, not_contains an AI label id (isAiLabel: true)
keyword_subject contains, not_contains text, matched anywhere in the subject
sender_address equals, contains an email address, or part of one
sender_domain equals, contains a domain, or part of one
date before, after, between an ISO 8601 date; for between, two dates joined by a pipe: `"2026-08-01
read_status is_true, is_false ignored: the operator carries the meaning
has_attachment is_true, is_false ignored

read_status and has_attachment are booleans: the meaning is carried by the operator, not by value. The schema still requires value to be present: pass an empty string.

Dates: before keeps emails strictly earlier than the date, after strictly later, between is inclusive at both ends. Prefer full ISO 8601 timestamps (2026-08-01T00:00:00Z); a bare 2026-08-01 is read as midnight UTC. An invalid or empty date is not rejected: the condition is silently dropped and matches every email. Validate dates on your side before sending them.

contains on sender_address is case-insensitive.

Folders are not a filter field. Restrict to a folder with the folderType query parameter, alongside filters.

Bounds

Bound Value Why
Nesting depth 5 each level multiplies the sub-queries to plan
Conditions in total 50 counted over the whole tree, not per group
Length of a value 500 contains becomes a LIKE %…%, which cannot use an index
Size of the raw parameter 8 KB checked before the JSON is even parsed

The count is global: spreading 60 conditions over two branches does not get around the cap of 50.

A tree that exceeds a bound returns 400 VALIDATION_FAILED, with the bound in details:

{
	"code": "VALIDATION_FAILED",
	"error": "Filter nesting is too deep — max 5 levels.",
	"details": { "maxDepth": 5 }
}

A tree within the bounds can still be expensive on a large mailbox. Execution is capped at 10 seconds; beyond that the query is cancelled and you get 408 QUERY_TIMEOUT.

Six examples

1. By sender

{ "id": "c1", "label": "sender_address", "type": "equals", "value": "client@acme.com" }
curl -G https://api.maylee.app/api/v1/emails \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  --data-urlencode 'filters={"id":"c1","label":"sender_address","type":"equals","value":"client@acme.com"}'

2. By mailbox

{ "id": "c1", "label": "mailbox", "type": "is", "value": "cmbx1a2b3c4d5e6f7g8h9i0j" }

Equivalent to ?mailboxId=. Useful inside a group, where a query parameter cannot express "this mailbox or that one".

3. By label

{ "id": "c1", "label": "label", "type": "contains", "value": "cmtg1a2b3c4d5e6f7g8h9i0j" }

For a label applied by the AI, use ai_label: it is a distinct field. Ids come from GET /labels, where isAiLabel tells them apart.

4. Unread in one mailbox, an AND

{
	"id": "g1",
	"_and": [
		{ "id": "c1", "label": "mailbox", "type": "is", "value": "cmbx1a2b3c4d5e6f7g8h9i0j" },
		{ "id": "c2", "label": "read_status", "type": "is_false", "value": "" }
	]
}

5. Two senders, an OR

{
	"id": "g1",
	"_or": [
		{ "id": "c1", "label": "sender_address", "type": "equals", "value": "a@acme.com" },
		{ "id": "c2", "label": "sender_address", "type": "equals", "value": "b@acme.com" }
	]
}

6. Nested groups

"Unread, and either from Acme or carrying the Quotes label":

{
	"id": "g1",
	"_and": [
		{ "id": "c1", "label": "read_status", "type": "is_false", "value": "" },
		{
			"id": "g2",
			"_or": [
				{ "id": "c2", "label": "sender_domain", "type": "equals", "value": "acme.com" },
				{ "id": "c3", "label": "label", "type": "contains", "value": "cmtg1a2b3c4d5e6f7g8h9i0j" }
			]
		}
	]
}

This tree is 2 levels deep. The limit of 5 leaves plenty of room for far richer queries.

?filters= and ?search= combine: the search term applies on top of the tree.

Mind the difference in how a term that is too short is treated:

  • on GET /emails, ?search= is an optional filter: a term shorter than two characters is ignored;
  • on GET /emails/search, the term is the point of the request: it is refused, because returning the whole mailbox would look like a successful search.

Saved views

A view is a filter tree saved in Maylee. Rather than rebuilding the same tree on every call:

curl -G https://api.maylee.app/api/v1/emails \
  -H "Authorization: Bearer $MAYLEE_KEY" \
  --data-urlencode 'viewId=cmvw1a2b3c4d5e6f7g8h9i0j'

GET /views lists the available views with their filters, in this very DSL. POST /views creates one from a tree, with the same bounds as ?filters=: name it, save it, and reuse it by id from every integration. See the reference.

Webhooks

Without webhooks, reacting to a new email means polling the API in a loop: expensive in quota, and always late. A webhook tells you.

Registering a webhook

Webhooks are managed from the Maylee app (Settings, Webhooks), by workspace owners and admins. They cannot be created or modified with an API key: a leaked key must not be able to redirect your workspace's events to an address of the attacker's choosing.

When you create one, you choose the URL and the events to subscribe to, and you receive a signing secret, shown once. It is what lets you tell an authentic delivery from a forged request.

Your URL must use https on port 443, carry no credentials, and resolve to a public address. Internal addresses (127.0.0.1, 10.0.0.0/8, 169.254.169.254 and the like) are refused.

A literal, non-routable IP address is rejected at creation. A host name is resolved at every delivery, not at creation: DNS can change in between, and a record pointing to an internal address would otherwise be validated once and for all.

In development this makes localhost and private tunnels untestable. Use a public receiver such as webhook.site, or an https tunnel.

The number of active webhooks is limited by plan: 1 on FREE, 5 on PREMIUM, 20 on EXPERT.

Events

Event Fired when
email.received an email arrives in an inbox
email.updated an email is marked read/unread or starred/unstarred
email.deleted an email is moved to the trash
draft.sent an email is sent from the workspace
label.applied a label is applied to an email
mailbox.sync_failed the synchronisation of a mailbox fails

email.received means a message arrived, not that a row was created: it is only fired for the inbox. Your own sent messages and your drafts do not trigger it, otherwise an agent would react to what it just wrote itself.

label.applied is fired once per email and label, only when the label was actually added. Applying a label an email already has does not fire it.

mailbox.sync_failed is fired at every failed attempt, each one counted in failCount. When deactivated is true, Maylee has stopped synchronising the mailbox and someone must reconnect it in the app.

Delivery payloads

Every delivery has the same envelope: the event name, when it was emitted, and a data object whose shape depends on the event.

{
	"event": "email.received",
	"createdAt": "2026-08-25T14:32:11.000Z",
	"data": {
		"emailId": "cmemx1a2b3c4d5e6f7g8h9i0",
		"mailboxId": "cmbx1a2b3c4d5e6f7g8h9i0j",
		"folderType": "INBOX"
	}
}

data by event:

Event data
email.received { "emailId", "mailboxId", "folderType": "INBOX" }
email.updated { "emailId", "changes": { "isRead": true } } or { "emailId", "changes": { "isStarred": true } }
email.deleted { "emailId" }
draft.sent { "mailboxId", "messageId", "threadId", "to": ["client@acme.com"], "subject" }
label.applied { "emailId", "tagId", "tagName", "isAiLabel" }
mailbox.sync_failed { "mailboxId", "error", "failCount", "deactivated" }

Payloads carry identifiers, not the content of the email. Call GET /api/v1/emails/{emailId} to fetch it, which guarantees that the content travels over an authenticated channel, and that your endpoint does not become a store of personal data by accident.

Two headers accompany every request:

X-Maylee-Event: email.received
X-Maylee-Signature: t=1787666985,v1=5257a869e7ec…

Verifying the signature

Do it systematically. Your endpoint is public: without verification, anyone can send you a fake event.

The signature is an HMAC-SHA256, keyed with your secret, of the timestamp and the raw body joined by a dot: ${t}.${rawBody}. Signing the body alone would let anyone who captured a delivery replay it forever.

import { createHmac, timingSafeEqual } from 'node:crypto'

const TOLERANCE_SECONDS = 300

function verify(secret, rawBody, header) {
	const parts = new Map(header.split(',').map((p) => p.trim().split('=')))
	const timestamp = Number(parts.get('t'))
	const provided = parts.get('v1')
	if (!timestamp || !provided) return false

	// Reject outside the window BEFORE comparing: a valid but old
	// signature is a replay.
	if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS)
		return false

	const expected = createHmac('sha256', secret)
		.update(`${timestamp}.${rawBody}`)
		.digest('hex')

	const a = Buffer.from(expected, 'hex')
	const b = Buffer.from(provided, 'hex')
	if (a.length !== b.length) return false

	// Constant-time comparison: `===` would leak, through the response
	// time, the position of the first differing byte.
	return timingSafeEqual(a, b)
}

Two classic pitfalls:

  • Sign the raw body, not the result of JSON.parse then JSON.stringify. The slightest difference in whitespace or key order invalidates the signature. With Express: express.raw({ type: 'application/json' }) on that route.
  • Do not compare with ===. Use a constant-time comparison.

Retries

A delivery succeeds if your endpoint answers 2xx within 10 seconds.

Anything else is retried, including a 4xx: an endpoint that answers 404 during a deployment gets the delivery once it is back. Five attempts in total:

Attempt Delay after the previous failure
1 immediate
2 5 min
3 30 min
4 2 h
5 6 h
then the delivery is abandoned

After three days of continuous failure, the webhook is disabled. An endpoint broken for three days rarely comes back on its own, and every event pays the price of a timeout. No event is emitted when this happens: check the webhook's status in the app. Re-enabling it there resets the failure counter.

Deliveries do not consume your API quota.

Good practices

Answer fast, process later. Return 200 as soon as you receive the request and do the work in the background. Taking more than 10 seconds turns the delivery into a failure, and you will receive it again.

Be idempotent. A 2xx lost on the way causes a retry: the same event can reach you twice. Deduplicate on data.emailId and createdAt.

Do not trust the order. Retries shift deliveries: an email.updated can arrive before the matching email.received. Re-read the current state through the API rather than rebuilding it from the sequence.

Errors

Every error has the same shape:

{
	"code": "INSUFFICIENT_SCOPE",
	"error": "This API key is missing the `write` scope. Edit the key in your workspace settings to add it.",
	"details": { "required": "write", "granted": ["read"] }
}

Branch your logic on code. The error field is written for humans and may be reworded; code is a stable contract. details is optional and carries the context useful to fix the request.

Only the codes below can be returned by /api/v1/*.

Authentication, 401

Code Meaning What to do
INVALID_API_KEY key missing, malformed or unknown check the Authorization: Bearer mk_… header
API_KEY_REVOKED the key was revoked create a new one in the workspace settings
API_KEY_EXPIRED the key's expiry date has passed create a new one; details.expiredAt says when

A session cookie never works on this surface, whatever its validity. This is deliberate. 401 responses carry no X-RateLimit-* headers: the key is resolved before the counters are read.

Authorization, 403

Code Meaning What to do
INSUFFICIENT_SCOPE the key lacks a scope details.required names it; details.granted lists what the key has
OUTBOUND_SEND_NOT_ALLOWED the action makes email leave the workspace and the key is not allowed to enable the outbound sending option on the key
WORKSPACE_SUSPENDED the workspace is suspended reactivate the subscription

OUTBOUND_SEND_NOT_ALLOWED is not a missing scope: it is a separate option, because write alone must not be enough to write to your customers' inboxes, or to forward their mail elsewhere, if the key leaks. It is returned by POST /drafts/send, by every write on /auto-forward-rules, and by /ai-label-rules when a request sets autoReplyEnabled: true.

Not found, 404

Code Returned by
EMAIL_NOT_FOUND GET /emails/{emailId}
NOT_FOUND GET /emails/{emailId}/body, PATCH /emails/{emailId}, DELETE /emails/{emailId}
DRAFT_NOT_FOUND GET /drafts/{draftId}
MAILBOX_NOT_FOUND POST /drafts, POST /drafts/send and POST /ai-label-rules/backfill, when mailboxId is unknown
LABEL_NOT_FOUND the /labels/{labelId} endpoints, applying a label to an email, and POST /emails/bulk with a label action
RULE_NOT_FOUND the /ai-label-rules/{ruleId} and /auto-forward-rules/{ruleId} endpoints
VIEW_NOT_FOUND the /views/{viewId} endpoints
FILTER_NOT_FOUND the /filters/{filterId} endpoints

A resource that belongs to another workspace returns 404, not 403: a 403 would confirm that it exists and allow enumeration. Treat EMAIL_NOT_FOUND and NOT_FOUND alike: both mean the email is not reachable with this key.

Request, 400

Code Meaning
VALIDATION_FAILED invalid payload, parameter or filter tree
INVALID_SENDER_ALIAS the from alias is not a verified send-as address of the mailbox
MAILBOX_INACTIVE the mailbox is disconnected; reconnect it in Maylee before sending
NO_TRASH_FOLDER the mailbox has no trash folder, the email cannot be moved
INVALID_TAG the label is an AI label: rename or delete it through its rule on /ai-label-rules
REQUEST_ERROR the request itself could not be read, typically malformed JSON

On VALIDATION_FAILED, details names the faulty fields:

{
	"code": "VALIDATION_FAILED",
	"error": "Invalid email payload.",
	"details": {
		"mailboxId": ["Required"],
		"to": ["Required"],
		"body": ["Required"]
	}
}

A business refusal returns 400, not 500: it describes a request you can fix. Retrying it unchanged will never succeed.

Timeout, 408

Code Meaning
QUERY_TIMEOUT the query ran for more than 10 seconds

Narrow the filters, lower limit, or target a single mailbox. The query was cancelled on the database side: it is not still running in the background.

Conflict, 409

Code Meaning
CONFLICT Idempotency-Key reused with a different body, or the original call is still in progress
DUPLICATE_TAG_NAME a label with this name already exists in the workspace
DUPLICATE_RULE an AI label with this name already exists in the workspace
DUPLICATE_FILTER an inbox filter with this type and value already exists in the workspace

For CONFLICT: in the first case, generate a new key, you are describing a new operation; in the second, retry in a moment. For the duplicates: label names are unique per workspace, across manual and AI labels alike, and so is the pair type + value of an inbox filter. Pick another name, or reuse the existing label or filter (details.filterId names it).

Payload too large, 413

Code Meaning
PAYLOAD_TOO_LARGE the JSON body exceeds 256 KB

Quotas, 429

Code Meaning What to do
RATE_LIMIT_EXCEEDED too many requests this minute wait Retry-After seconds; details.resetAt says when the window resets
QUOTA_EXCEEDED monthly allowance exhausted wait for the 1st of next month (UTC), or upgrade the plan; details.used and details.quota give the numbers

Do not confuse them: waiting fixes the first, never the second.

Server, 500

Code Meaning
INTERNAL_ERROR unexpected failure, including a refusal from the email provider on POST /drafts/send

This is the only case where retrying the same request makes sense. On a send, nothing was sent and the idempotency key is released, so a retry with the same key executes the send again rather than replaying a failure.

If a 500 persists on a request you believe is valid, it is most likely a bug on our side: report it with the timestamp and the path you called.