Search inbound emails
Changed onSearches inbound emails with structured filters and optional full-text matching across parsed email fields. This endpoint is optimized for filtered inbox views and CLI polling workflows: callers that only need new accepted mail can pass sort=received_at_asc, snippet=false, include_facets=false, and a date_from timestamp.
q, subject, and body use the same English full-text index as the web inbox search. Structured filters such as from, to, domain_id, status, attachment presence, and spam score bounds are combined with the text query.
The text parameters (q, from, to, subject and body) must not contain a NUL character; a value that does is rejected with a 400 validation error.
Connected-agent credentials search only mail received by their own address. This applies to results, totals, facets, and every page; search filters cannot widen the credential's scope. When reply_to_sent_email_id is supplied, its parent send must belong to the connected address in the same organization. An unavailable parent returns 404. Sender filters are not authentication proof; inspect the email detail's authentication evidence before trusting it.
Request
- Base URL: https://api.primitive.dev/v1
- URL: https://api.primitive.dev/v1/emails/search
- Auth: HTTP bearer
Query parameters
Full-text search DSL query. Supports awaiting:you and awaiting:them to filter on reply state (see the awaiting field; delivered mail only, never rejected). Supports automated:true and automated:false to filter on the automated field. Terms are separated by spaces and every term must match. A term is a word, a "quoted phrase", or a field:value filter (from:, to:, subject:, body:, has:attachment, before:, after:, domain:, status:, awaiting:, automated:). An upper-case OR between two text terms (words, phrases, subject: and body: terms) accepts either, and binds tighter than the implicit AND: acme invoice OR receipt is acme and either invoice or receipt. OR cannot join the other filters, and there is no NOT and no parentheses. A query that uses an unsupported operator, an unknown field, an unterminated quote, or only stop words is a validation_error that says what to change; it is never silently reinterpreted. Alternatives chain (invoice OR receipt OR "past due"), and each one counts toward the limit of 32 terms. These are rejected with a 400 validation_error: OR next to a filter (from:a@example.com OR from:b@example.com; run one search per value instead); an OR with no term on one side (a leading, trailing or doubled OR); an upper-case NOT (a search cannot exclude a term); and an alternative with no searchable word once stop words are removed (the OR invoice). Only upper case is an operator: to search for the word OR or NOT itself, write it in lower case or in double quotes ("OR"). With prefix=true, a trailing OR or NOT is read as a word still being typed. To search for text that contains a colon, such as a URL, put it in double quotes.
Filter by sender address or sender domain.
Filter by recipient address or recipient domain.
Full-text search restricted to the subject field.
Full-text search restricted to the parsed text body.
Filter by domain ID.
Filter to inbound emails that are replies to a specific outbound send. The value is a sent_emails.id (UUID). At inbound ingest, Primitive matches the parsed In-Reply-To header (or References as a fallback) against sent_emails.message_id in the same org and records the resolved id on emails.reply_to_sent_email_id. This filter is the strict-threading lookup behind primitive chat and any UI that wants to show the inbound reply to a given send. NULL on inbound that isn't a threaded reply to one of your sends, so existing emails received before this ingestion landed will not match.
Lifecycle status of an INBOUND email (a row in the emails table). Distinct from SentEmailStatus, which describes the OUTBOUND lifecycle (the sent_emails table) and uses a different vocabulary because the lifecycles differ. Possible values:
- pending: the row was inserted at ingestion (mx_main) and has not yet completed the spam / filter / auth pipeline. Body and parsed fields are present; webhook delivery is not yet scheduled. Most rows transition out of pending within seconds.
- accepted: the inbound passed the policy gates and is queued for webhook delivery. The webhook_status field tracks the separate webhook-delivery lifecycle from this point.
- completed: terminal success. Webhook delivery attempted and acknowledged by every active endpoint, OR no endpoints are configured, so the row is durably archived.
- rejected: terminal failure at ingestion (spam, blocked sender, filter rule, malformed). The body and metadata are stored for auditing but no webhook fires and the row is not repliable.
See also webhook_status (separate enum tracking the webhook-delivery state machine) and SentEmailStatus (the outbound vocabulary).
Filter by inbound email lifecycle status.
Filter emails received on or after this timestamp. An ISO 8601 timestamp with Z or a numeric UTC offset (2026-10-02T00:00:00Z, 2026-10-02T00:00:00-04:00). An offset is read as the instant it names. A time with no zone, or a bare date, is rejected.
Filter emails received on or before this timestamp. An ISO 8601 timestamp with Z or a numeric UTC offset (2026-10-02T00:00:00Z, 2026-10-02T00:00:00-04:00). An offset is read as the instant it names. A time with no zone, or a bare date, is rejected.
Filter by whether the email has one or more attachments.
Filter to emails with spam score below this value.
Filter to emails with spam score greater than or equal to this value.
Only return emails whose awaiting has this value. Also available in q as awaiting:you or awaiting:them. The filter (either value, here, on search and as the awaiting: search term) matches delivered mail only: emails whose status is rejected are never returned by it. A rejected email (for example one refused because the organization is over its storage limit) was never delivered, so nobody can answer it and it is awaiting no one; its own awaiting field still reports the stored value, usually you. Whose turn it is in this email's conversation. A send counts as a reply unless its status is gate_denied, agent_failed, canceled (queued and scheduled replies count: they are committed to go). For an email with a thread_id: them when the thread's latest counted outbound send (by send created_at, including any counted reply to this email itself) is at or after the thread's latest inbound message (by received_at); otherwise you. For an email with no thread_id: them when it has at least one counted reply (reply_count > 0 for an API key), otherwise you. It is a thread-level fact: every email in a thread carries the same value, and it reflects sends made by any credential in the organization, including other agent connections. It changes without a new inbound message, for example when a reply is sent, a queued reply fails, a scheduled reply is canceled, or a message is deleted, and is always current when read.
Only return emails whose automated has this value. Also available in q as automated:true or automated:false. An inbound email is automated when any of these holds, decided once when it arrives: null_envelope_sender (an empty MAIL FROM, a bounce); no_identifiable_sender (no address in the envelope or From); own_address (a sender address is exactly one of the addresses the mail was delivered to; sharing a domain with the recipient is not enough, so a colleague or another agent in the organization is not automated); mailer_daemon (mailer-daemon@ or postmaster@ on any domain); auto_submitted (Auto-Submitted with any keyword but no, an empty one included); precedence (Precedence bulk, list, junk or auto_reply); list_unsubscribe (List-Unsubscribe present); list_id (List-Id present); auto_response_suppress (X-Auto-Response-Suppress asks for no automatic reply: All, OOF or AutoReply); failed_recipients (X-Failed-Recipients present, a bounce); report (a delivery, feedback or disposition report). Mail Primitive's own automatic responders decline to answer is always automated. The header rules only see headers captured at ingest, so older mail may lack them. This is loop and noise protection, not sender authentication.
Sort mode. Defaults to relevance when a text query is present, otherwise received_at_desc.
Opaque pagination cursor from a previous search response.
Number of results per page
Include subject/body highlight snippets when text search is active. This switches only the highlights object on each result. The snippet field (the body preview) is on every result either way, and a search with no text (q, subject or body) has no highlights to leave out, so false changes nothing there.
Include facet counts for sender, domain, status, and attachment presence. When false, the facet aggregation is not run at all. Each of by_sender, by_domain and by_status lists its values by count descending, and values with the same count by value ascending in byte order of its UTF-8 encoding (upper case before lower case), a null value last. by_sender and by_domain keep the first 20 values in that order, so the same request over the same mail always lists the same values.
Only return emails in this thread (the thread_id each result carries). Combines with q and every other filter, and with pagination. Visibility is unchanged; an agent connection still sees only mail received by its address.
Search-as-you-type. When true, the final word of q is treated as still being typed and matches any word that begins with it, so q=quarterly invoi finds "invoice" and "invoicing". Only the final word is affected, and only when it is unquoted text (a bare word or subject:/body: with a bare value); a quoted phrase or a field filter in the final position leaves the query unchanged. The final word is kept even when it is a stop word, so q=the matches "theory". Matching is against the same stemmed index as a whole-word search, so a partial word whose spelling diverges from its stem (runni for "running") may not match until the word is complete. The text is never interpreted as query syntax: operators such as |, !, &, :* and <-> are read as ordinary characters. Defaults to false.
When false, the total match count is not computed and meta.total is null (meta.total_capped is false). Use meta.cursor to tell whether another page exists. Skipping the count makes a broad query, such as a one-letter prefix, cheaper to serve. Defaults to true.
Response
Search results
Changes
- info
added the optional property
///////to the response with the status
- info
- breaking
the response property
//became nullable for the status - info
added the new optional
queryrequest parametercount - info
added the new optional
queryrequest parameterprefix - info
added the new optional
queryrequest parameterthread_id - info
added the required property
////to the response with the status - info
added the required property
////to the response with the status
- breaking