# Search

> Full text search over the messages of an inbox, with the criteria applied to the message.

Source: https://developer.mueslimail.com/docs/search/

`GET /v0/inboxes/{inbox}/messages/search` searches every message of one inbox on the server. It is not a filter over a page you already loaded: a query reaches the whole inbox, including mail your client has never seen.

## What comes back

The criteria apply to the **message**, not to the thread. A message that matches brings its `thread_id` with it, and the answer is a list of messages, newest first, paginated with `limit` (1 to 100, default 20) and `cursor` (from `next_cursor`). Group the hits by `thread_id` to show conversations; every hit also carries `snippet`, an excerpt around the match.

A negation excludes the **message**, never the thread: `-from:noreply` hides that message, and the rest of its conversation stays findable through the other messages. Criteria of different fields are ANDed. The values of one repeated parameter (`from`, `to`, `subject`, `label`) are ORed.

## The query language

* Free text matches message text, subject and sender, ignoring case and accents: `fattura` finds `Fattura` and `perché` is found by `perche`.
* `"fattura marzo"` in quotes is an exact phrase: adjacent words in that order. It does not match `marzo fattura`.
* A leading minus excludes: `-from:noreply@example.com`, `-subject:newsletter`, `-label:archived`.
* `from:`, `to:`, `subject:` and `label:` take a value, and can be negated with a minus.
* `is:` accepts `unread`, `read` or `flagged`. `read` means "no unread label", because the unread label is the only read state a message carries.
* `has_attachment:true` keeps only messages with files; `has_attachment:false` only those without.
* `after:` and `before:` take an RFC 3339 timestamp (or pass `after=` and `before=` as parameters); both are inclusive.

```bash
curl -s -G https://api.mueslimail.com/v0/inboxes/{inbox}/messages/search \
  -H "Authorization: Bearer $MAI_API_KEY" \
  --data-urlencode 'q="fattura marzo" -from:noreply' \
  --data-urlencode 'has_attachment=true' \
  --data-urlencode 'after=2026-01-01T00:00:00Z' \
  --data-urlencode 'limit=50'
```

> Note: The same search is available as parameters instead of `q`: `?from=&to=&subject=&has_attachment=&label=&is=&after=&before=`. A value the endpoint cannot honour, such as `is:robot` or a malformed timestamp, is a `400 validation_error`, never a silently ignored filter.

## The economical permission

`message_read` returns the mail, with a `snippet` for every hit. `message_metadata` is the narrower permission: a key holding only that one matches the headers (subject and sender) and receives no body, no preview and no snippet. It is what an economical sync or a routing agent should hold: it can find a message and route it without reading it. Asking for a body word returns nothing for such a key, so the permission cannot be used to probe text it may not read.

## Pagination

The cursor is a keyset over `(created_at, id)` descending, so a message that arrives while you read page two neither shifts nor repeats a row of page one: new mail is newer than the cursor, which is the one direction the next page does not look.

## Endpoints

### GET /v0/inboxes/{inbox}/messages/search

Search the messages of an inbox, full text (requires message_read or message_metadata)

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `inbox` | string (email) | in path, required |
| `q` | string | in query, Free text with quoted exact phrases, -exclusions and field:value tokens |
| `from` | string[] | in query, Sender: case and accent insensitive substring of the From header, repeatable, ORed |
| `to` | string[] | in query, Recipient (To or Cc): repeatable, ORed |
| `subject` | string[] | in query, Subject substring, repeatable, ORed |
| `has_attachment` | boolean | in query, Only messages that carry an attachment, or only those that do not |
| `label` | string[] | in query, Label, repeatable, ORed (any of them) |
| `is` | unread \| read \| flagged | in query, read, unread or flagged |
| `after` | string (date-time) | in query, Inclusive lower bound on created_at |
| `before` | string (date-time) | in query, Inclusive upper bound on created_at |
| `limit` | integer | in query, min 1, max 100, default 20 |
| `cursor` | string | in query, next_cursor of the previous page |
| `include_review` | boolean | in query, Also return messages held for security review; needs message_review, default false |

Responses:

* `200` OK: SearchResult
* `400` Error envelope: Error
* `403` Error envelope: Error
* `404` Error envelope: Error

### SearchResult object

| Name | Type | Notes |
| --- | --- | --- |
| `count` | integer |  |
| `next_cursor` | string | Pass as cursor= for the next page; absent on the last one |
| `messages` | Message[] |  |

### Message object

| Name | Type | Notes |
| --- | --- | --- |
| `id` | string (uuid) |  |
| `message_id` | string | RFC 5322 Message-ID with angle brackets |
| `thread_id` | string (uuid) |  |
| `inbox_id` | string |  |
| `direction` | in \| out |  |
| `from` | string |  |
| `to` | string[] |  |
| `cc` | string[] |  |
| `subject` | string |  |
| `text` | string |  |
| `html` | string |  |
| `extracted_text` | string | New text without quoted history (set by the gatekeeper) |
| `preview` | string |  |
| `snippet` | string | Excerpt around the match. Only in search results, and empty for a key holding message_metadata without message_read |
| `labels` | string[] |  |
| `in_reply_to` | string |  |
| `references` | string[] |  |
| `status` | received \| queued \| sending \| sent \| failed \| held \| cancelled |  |
| `delivery_error_code` | unauthorized_ip \| auth_failed \| relay_unreachable \| connection_refused \| tls_failed \| unknown | Why an outbound message did not leave. Set when a delivery attempt was refused, absent otherwise; with status failed, the message is not delivered and the console says so. |
| `delivery_error` | string | What the mail server said when it refused the message, one line, at most 500 characters, never a credential. |
| `last_error` | string | The last reason a message did not leave, from the transport or from the platform: a send held by undo send whose window closed while sending was paused fails with the reason of the pause here, and no delivery_error, because no mail server refused it. |
| `cancel_window_ends_at` | string (date-time) | End of the undo window of a send born held. Before it, POST .../cancel withdraws the message; after it, the message is on its way and cancel answers 409 invalid_state. Absent on a message that never had a window. |
| `authentication_results` | object | What the SMTP edge verified for inbound mail: dkim, spf, dmarc, dmarc_policy, antivirus, spam_score, spam_action. Mail failing DMARC is labelled dmarc-fail, and spam under p=quarantine or p=reject. |
| `gatekeeper` | object |  |
| `screening` | review \| allowed \| released | Hold state under screen before notify; absent when the message was not held. Pending messages are never returned. |
| `attachments` | object[] |  |
| `created_at` | string (date-time) |  |
