# Filters

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

Source: https://docs.maylee.app/filters

`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](#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](#bounds) below.

> **MCP server**
>
> The MCP server uses a simpler grammar for the same filters (`match`, `conditions`, `groups`): see [Reference data](/mcp/reference-data#filter-grammar).

## Shape

Two kinds of nodes, and nothing else.

**A condition:**

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

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

```json
{ "id": "g1", "_and": [] }
```

```json
{ "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:

```json
{
  "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 **manual** label id (`isAiLabel: false`), from `GET /labels`. An AI label id matches nothing here.       |
| `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-01T00:00:00Z\|2026-08-31T23:59:59Z`. |
| `read_status`     | `is_true`, `is_false`        | Ignored: the operator carries the meaning. Pass an empty string.                                           |
| `has_attachment`  | `is_true`, `is_false`        | Ignored: 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

| 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 characters | `contains` becomes a substring scan, 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 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

```bash
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

```json
{ "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

```json
{ "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

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

### Two senders

```json
{
  "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":

```json
{
  "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.

## Filters and search

> **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:

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

[`GET /views`](/api-reference/views/list-views) lists the views with their `filters` in this very format, and [`POST /views`](/api-reference/views/create-view) creates one from a tree, with the same bounds as `?filters=`.
