Filters

The JSON filter tree of GET /emails and saved views, its fields and operators, its bounds, and worked examples.

GET /emails accepts a JSON filter tree through ?filters=. It composes a query as precise as you need without Maylee having to anticipate every combination, which is what an agent needs when it builds its request at run time. Saved views store trees in the same format.

The tree is translated directly into a database query, and a few lines of JSON can ask for a lot of work: hence the bounds below.

MCP server

The MCP server uses a simpler grammar for the same filters (match, conditions, groups): see Reference data.

Shape

Two kinds of nodes, and nothing else.

A condition:

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

A group, which combines its children with AND or OR:

{ "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 has nothing to do with email labels), type 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 does not return the whole mailbox but an error naming the faulty field, with the 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
mailboxis, is_notA mailbox id, from GET /mailboxes.
labelcontains, not_containsA manual label id (isAiLabel: false), from GET /labels. An AI label id matches nothing here.
ai_labelcontains, not_containsAn AI label id (isAiLabel: true).
keyword_subjectcontains, not_containsText, matched anywhere in the subject.
sender_addressequals, containsAn email address, or part of one.
sender_domainequals, containsA domain, or part of one.
datebefore, after, betweenAn ISO 8601 date. For between, two dates joined by a pipe: 2026-08-01T00:00:00Z|2026-08-31T23:59:59Z.
read_statusis_true, is_falseIgnored: the operator carries the meaning. Pass an empty string.
has_attachmentis_true, is_falseIgnored: pass an empty string.
  • read_status and has_attachment are booleans: the meaning is in the operator, but the schema still requires value, so pass "".
  • before keeps emails strictly earlier than the date, after strictly later, and between is inclusive at both ends. Prefer full timestamps (2026-08-01T00:00:00Z): a bare 2026-08-01 means midnight UTC.
  • An invalid or empty date is not rejected: the condition is dropped and matches every email. Validate dates before sending them.
  • contains on sender_address ignores case.
  • Folders are not a filter field: restrict to a folder with the folderType query parameter, alongside filters.

Bounds

BoundValueWhy
Nesting depth5Each level multiplies the sub-queries to plan.
Conditions in total50Counted over the whole tree, not per group.
Length of a value500 characterscontains becomes a substring scan, which cannot use an index.
Size of the raw parameter8 KBChecked before the JSON is even parsed.

The count is global: spreading 60 conditions over two branches does not get around the limit of 50. A tree that exceeds a bound returns 400 VALIDATION_FAILED with the bound in details (maxDepth, maxConditions, maxValueLength or maxBytes). A tree within the bounds can still be expensive on a large mailbox: execution stops after 10 seconds with 408 QUERY_TIMEOUT.

Examples

By sender

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"}'

By mailbox

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

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

By label

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

label only matches manual labels. 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.

Unread in one mailbox

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

Two senders

{
  "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" }
  ]
}

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 room for much richer queries.

Do not combine search and filters

On GET /emails, when ?search= holds a term of two characters or more, the filter tree of ?filters= (and filtered=only) is currently ignored: only the search term, mailboxId, folderType and viewId apply. To match text inside a filter tree, use a keyword_subject, sender_address or sender_domain condition instead.

A term that is too short is treated differently by the two endpoints:

  • 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 and shown in the sidebar of the app. Rather than rebuilding the same tree on every call, apply it by id:

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

GET /views lists the views with their filters in this very format, and POST /views creates one from a tree, with the same bounds as ?filters=.

On this page