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 inRetry-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 viewswrite: 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.
5. Search
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:
inReplyTois themessageIdof the email you answer.referencesis the original'sreferencesarray plus itsmessageId, in that order. On a first reply the original has no references, so the array holds the singlemessageId.- Reply to
fromof the original, and prefix the subject withRe:unless it already starts with it. - Send from the same
mailboxIdthe 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.
| Method | Path | Scope | Summary |
|---|---|---|---|
| DEL | /ai-label-rules/{ruleId} | write | Delete an AI label rule |
| DEL | /auto-forward-rules/{ruleId} | write | Delete an auto-forward rule |
| DEL | /emails/{emailId} | write | Move an email to trash |
| DEL | /emails/{emailId}/labels/{labelId} | write | Remove a label from an email |
| DEL | /filters/{filterId} | write | Delete an inbox filter |
| DEL | /labels/{labelId} | write | Delete a label |
| DEL | /views/{viewId} | write | Delete a view |
| GET | /ai-label-rules | read | List AI label rules |
| GET | /ai-label-rules/{ruleId} | read | Get an AI label rule |
| GET | /auto-forward-rules | read | List auto-forward rules |
| GET | /auto-forward-rules/{ruleId} | read | Get an auto-forward rule |
| GET | /auto-forward-rules/{ruleId}/logs | read | List the forwarding attempts of a rule |
| GET | /drafts | read | List drafts |
| GET | /drafts/{draftId} | read | Get a draft |
| GET | /emails | read | List emails |
| GET | /emails/{emailId} | read | Get an email |
| GET | /emails/{emailId}/body | read | Get an email body and its thread |
| GET | /emails/search | read | Full-text search |
| GET | /filters | read | List inbox filters |
| GET | /filters/{filterId} | read | Get an inbox filter |
| GET | /labels | read | List labels |
| GET | /labels/{labelId} | read | Get a label |
| GET | /mailboxes | read | List connected mailboxes |
| GET | /signatures | read | List signatures |
| GET | /views | read | List saved views |
| GET | /views/{viewId} | read | Get a view |
| PATCH | /ai-label-rules/{ruleId} | write | Update an AI label rule |
| PATCH | /auto-forward-rules/{ruleId} | write | Update an auto-forward rule |
| PATCH | /emails/{emailId} | write | Update read or starred state |
| PATCH | /filters/{filterId} | write | Update an inbox filter |
| PATCH | /labels/{labelId} | write | Rename or recolor a label |
| PATCH | /views/{viewId} | write | Update a view |
| POST | /ai-label-rules | write | Create an AI label rule |
| POST | /ai-label-rules/backfill | write | Classify existing emails with the AI label rules |
| POST | /auto-forward-rules | write | Create an auto-forward rule |
| POST | /drafts | write | Create a draft |
| POST | /drafts/send | write | Send an email |
| POST | /emails/bulk | write | Bulk mark-read, delete, apply or remove a label |
| POST | /emails/{emailId}/labels/{labelId} | write | Apply a label to an email |
| POST | /filters | write | Create an inbox filter |
| POST | /labels | write | Create a label |
| POST | /views | write | Create 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) and500 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. |
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 withPOST /drafts/sendis 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/sendand 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
?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.parsethenJSON.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.