# mail-query
[](https://crates.io/crates/mail-query)
[](https://docs.rs/mail-query)
[](#license)
Parse Gmail-style email search queries into a typed AST you can translate
to your search backend.
## Install
```toml
[dependencies]
mail-query = "0.1"
```
```rust
use mail_query::parse;
let ast = parse("from:alice subject:\"deploy\" is:unread after:2026-01-01")
.expect("valid email search query");
// Walk the AST with Visitor, or pattern-match QueryNode, to translate it
// to Tantivy, Meilisearch, SQL FTS, IMAP SEARCH, or your own backend.
```
## Why this crate exists
Email clients often expose Gmail-style search even when the storage or
index backend is something else: Tantivy, Meilisearch, SQL FTS, IMAP
SEARCH, or an application-specific service.
`mail-query` keeps that boundary narrow: parse the user-facing email
search syntax into a portable AST, then let the caller choose how to
execute it.
## What it does
- Parses Gmail's documented operator surface from
<https://support.google.com/mail/answer/7190>:
- Address fields: `from:`, `to:`, `cc:`, `bcc:`, `deliveredto:`,
`rfc822msgid:`, `list:`
- Content fields: `subject:`, `body:`, `filename:`
- `is:` and `has:` filters
- `label:` and `category:`
- `size:`, `larger:`, `smaller:` with unit suffixes (`5M`, `200K`)
- `after:`, `before:`, `date:`, `older:`, `newer:`, `older_than:`,
`newer_than:` with both specific dates and relative durations
(`older_than:5d`)
- `AND` / `OR` / `NOT` / `-` / parentheses / brace groups
- `AROUND<n>` for word proximity
- Recognises `+word` as an exact-match (no-stemming) hint, mirroring
Gmail's syntax.
- Round-trips: `parse(node.to_string())? == node` (structural equality,
not byte identity).
- Walks the AST via a [`Visitor`] trait so backend authors can translate
to their own query language.
- Exposes extension points for caller-specific filters: register names
via [`ParserOptions::register_custom_filter`] and they route through
[`FilterKind::Custom`].
## What it does not do
- It does **not** execute queries. The output is a portable AST; you
pick the backend.
- It does **not** resolve `older_than:5d` to a concrete date at parse
time. The AST carries `DateValue::Relative { amount, unit }`;
backends call `ParserOptions::now_provider` at execution time. This
keeps saved queries relative across executions and lets the AST
round-trip without embedding a date.
- It does **not** parse IMAP SEARCH grammar (RFC 3501 §6.4.4). The
vocabularies overlap, but the grammars are separate concerns.
## Intentional divergences
These are decisions where the crate is narrower or more opinionated
than the Gmail surface.
- **`older_than:5d` is `Relative`, not a resolved `NaiveDate`.** See
above.
- **`+word` is a distinct AST variant `Exact`, not `Text`.** The no-
stemming hint is preserved so backends can act on it.
- **OR has lower precedence than AND.** `a b OR c` parses as
`(a AND b) OR c`. Matches Gmail's documented behaviour and Lucene
convention.
- **Unknown filters error by default.** A bare `is:my-app-flag` returns
[`ParseError::UnknownFilter`] unless the caller has registered it via
[`ParserOptions::register_custom_filter`]. This is the
default-strict posture; opt in to widen.
## Extensibility
Filter names Gmail adds over time, color-star variants beyond the
common set, or your application's own `is:owed-reply` — register them
once at construction time:
```rust
use mail_query::{parse_with, FilterKind, ParserOptions, QueryNode};
let mut options = ParserOptions::new();
options.register_custom_filters(["owed-reply", "reply-later"]);
let ast = parse_with("is:owed-reply", &options).expect("custom filter parses");
assert_eq!(
ast,
QueryNode::Filter(FilterKind::Custom("owed-reply".into()))
);
```
The crate canonicalises names to lowercase + hyphenated form, so
`is:owed_reply` and `is:Owed-Reply` both resolve to
`Custom("owed-reply")`.
## Visitor
```rust
use mail_query::{parse, FilterKind, Visitor};
#[derive(Default)]
struct CountFilters(usize);
impl Visitor for CountFilters {
fn visit_filter(&mut self, _: &FilterKind) {
self.0 += 1;
}
}
let ast = parse("from:alice is:unread OR has:attachment").expect("query parses");
let mut counter = CountFilters::default();
counter.walk(&ast);
assert_eq!(counter.0, 2);
```
The default `walk` implementation recurses into `And` / `Or` / `Not`
and dispatches to typed `visit_*` hooks for leaves. Override only what
you need.
## Forward compatibility
Every public enum is `#[non_exhaustive]`. New variants (for new Gmail
operators) are non-breaking additions. Pattern-matching callers must
include a `_ => …` arm.
## Conformance
The full coverage matrix lives in
[`testdata/coverage.md`](./testdata/coverage.md). Each fixture is a
language-neutral JSON file under [`testdata/conformance/`](./testdata/conformance/)
so another implementation can adopt the same corpus.
Three tests enforce the integrity of the corpus:
1. Every fixture file is referenced in `coverage.md`.
2. Every contract-critical fixture exists on disk.
3. The actual parser output matches `expected_ast` (or
`expected_error`) for every fixture.
```bash
cargo test --all-features
```
## Feature flags
- `serde` — adds `Serialize`/`Deserialize` derives to every AST type,
with `chrono/serde` enabled for `NaiveDate`. Default off.
Required dependencies: `chrono` with `clock` enabled, and `thiserror`.
## Out of Scope
These are intentionally outside this crate's current surface:
- Tantivy interop: `From<tantivy_query_grammar::UserInputAst>` and
back, behind an optional feature.
- IMAP SEARCH grammar (RFC 3501 §6.4.4) parsing as a separate
normalisation layer.
- WASM or npm packaging that consumes the same conformance corpus.
Request one with a concrete backend, grammar, or packaging use case.
## Maintenance
- File bug reports at
<https://github.com/planetaryescape/mail-query/issues>.
- Patches that change behaviour must add or update a fixture in
`testdata/conformance/` and a row in `testdata/coverage.md`.
## License
MIT OR Apache-2.0. See [LICENSE-MIT](./LICENSE-MIT) and
[LICENSE-APACHE](./LICENSE-APACHE).