Skip to content

planetaryescape/mail-query

Repository files navigation

mail-query

Crates.io Documentation License

Parse Gmail-style email search queries into a typed AST you can translate to your search backend.

Install

[dependencies]
mail-query = "0.1"
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:

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

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. Each fixture is a language-neutral JSON file under 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.
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

License

MIT OR Apache-2.0. See LICENSE-MIT and LICENSE-APACHE.

About

Parser and typed AST for Gmail-style email search queries. Backend-agnostic.

Resources

License

Apache-2.0, MIT licenses found

Licenses found

Apache-2.0
LICENSE-APACHE
MIT
LICENSE-MIT

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages