ContactCard/query returns 0 results when sorted by created/updated with no filter (Meilisearch)

Issue Description

With Meilisearch as the search store, ContactCard/query returns total: 0 when it sorts by created or updated and has no filter. The same query without the sort returns every card. The same sort with any filter also returns the correct cards.

Cause (v0.16.23, same on main 23f76bd): contact/query.rs turns a created/updated sort into a local SortedSet comparator. SearchQuery::with_account_id adds one SearchFilter::Operator(AccountId). In SearchStore::query_account (store/src/dispatch/search.rs), the AccountId operator does not set has_external_filters. The comparator is local, so the query skips the mask shortcut and the external branch. The filters then reach the local SearchQuery::filter() (store/src/search/local.rs). That loop skips the Operator with continue, so state.bm stays None, and unwrap_or_default() returns an empty bitmap.

Suggested fix: strip the AccountId operator before the local merge, or use query.mask when no other filter remains.

Expected Behavior

An unfiltered ContactCard/query sorted by created or updated returns every card in the account, in that order.

Actual Behavior

It returns ids: and total: 0. Clients that list an address book in a stable order show it as empty. Example: @bryanberger/jmap-mcp contacts_search with no criteria.

Reproduction Steps

  1. Use Meilisearch as the search store, and have an account with contact cards (510 here).
  2. Send:
    [“ContactCard/query”, {“accountId”: “c”, “calculateTotal”: true,
    “sort”: [{“property”: “created”, “isAscending”: true}]}, “0”]
  3. Result: total 0.

Results on the same account:

  • no filter, no sort: 510
  • no filter, sort created asc or desc: 0
  • no filter, sort updated: 0
  • inAddressBook “b”, sort created: 508
  • email “gmail”, sort created: 79

This is not a stale index. The unsorted query and the filtered sorted queries return the full set.

Relevant Log Output

Nothing is logged. The query succeeds, but it returns 0 results.

Stalwart Version

v0.16.x

Installation Method

Binary (Linux)

Database Backend

FoundationDB

Blob Storage

S3-compatible

Search Engine

Meilisearch

Directory Backend

Internal

Additional Context

The internal FTS store takes the internal_fts() path, so it is probably not affected. I did not test it. Redis Cluster is the in-memory store and NATS is the coordinator; neither should matter here. I checked the forum and GitHub for duplicates and found none.

I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.

on

I have searched this support forum (open and closed topics) and confirm this is not a duplicate.

on

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

Your analysis matches what’s in the source. Looking at query_account in crates/store/src/dispatch/search.rs:

  • The AccountId operator is matched by its own dedicated arm (SearchField::AccountId, SearchOperator::Equal) which just records account_id and does not set has_external_filters.
  • With a created/updated sort and no other filter, the comparator is a local SortedSet, so it’s not is_external(), and has_local_filters is also false (no DocumentSet filter present). That means the “mask shortcut” return path (!has_local_filters && !has_external_filters && comparators.is_empty()) is skipped because comparators aren’t empty, and the “all external comparators” shortcut is also skipped because the comparator is local.
  • Execution falls through to the local merge branch, which calls SearchQuery::new(...).with_filters(filters).filter() using the original query.filters (still containing the AccountId operator, since has_external_filters was never set to true).
  • In store/src/search/local.rs, the filter loop’s SearchFilter::Operator { .. } => { ... continue; } arm skips the AccountId operator without touching state.bm, so state.bm remains None and unwrap_or_default() yields an empty bitmap, i.e. zero results.

So the root cause is exactly what you described: the AccountId operator filter is silently dropped by the local filter loop instead of being resolved against the mask, and it isn’t classified as an “external filter” that would otherwise route the query differently. Your two suggested fixes (stripping the AccountId operator before the local merge, or falling back to query.mask when no other filter remains) both look like they’d address it.

I’ve confirmed this from the current source, this is a real bug report and not something covered by existing documentation, so a staff or community member will follow up on a fix.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

This had been fixed as part of the v1.0.0 refactor and it can’t be backported to v0.16. v1 is expected for late October early November.