Skip to content

fix(api-search): ZMS-94: keep $text queries plannable inside OR branches - #1152

Merged
NickOvt merged 2 commits into
masterfrom
ZMS-94
Aug 26, 2026
Merged

fix(api-search): ZMS-94: keep $text queries plannable inside OR branches#1152
NickOvt merged 2 commits into
masterfrom
ZMS-94

Conversation

@NickOvt

@NickOvt NickOvt commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Problem

GET /users/:user/search returned HTTP 500 for any query that puts a fulltext term inside an OR branch, for example the reported

?q=from%3A1.1.2021+OR+to%3A31.12.2024+enico&searchable=1&useAndSearch=1
planner returned error :: caused by :: failed to use text index to satisfy $text query
(if text index is compound, are equality predicates given for all prefix fields?)

MongoDB puts two conditions on a $text expression nested inside an $or:

  1. the fulltext index is compound ({user: 1, 'headers.value': text, text: text}), so the branch holding $text needs its own equality match on user. A top level user is not propagated into the branch.
  2. every other branch of that same $or has to be index backed, otherwise the planner gives up with No query solutions.

On top of that MongoDB accepts only one $text expression per query, and the builder emitted two whenever text terms landed in different OR branches (Too many text expressions).

Fix

applyTextIndexPrefix(filter, user) walks the finished filter and stamps user onto every branch of every $or that wraps a $text clause. The filter always requires user at the top level, so the repeated equality never changes what the filter matches, but it gives the text branch its index prefix and gives the sibling branches an index to scan. Both filter builders call it, so the or.* API params (lib/prepare-search-filter.js) are covered as well, not only q= (lib/search-query.js).

A textQueryUsed guard keeps a single $text per query. Terms that cannot claim it fall back to the regex matching the builder already uses for quoted phrases. That fallback now mirrors $text semantics: a merged AND term stays ANDed, and a negated term keeps excluding instead of widening the result set.

Verified

Against MongoDB 7 with the full messages index set from indexes.yaml:

  • 42 query shapes that previously produced failed to use text index, No query solutions or Too many text expressions now all plan, every one of them as TEXT_MATCH over the fulltext index, no collection scans
  • 24 seeded corpus queries return exactly the documents the query logically selects, including the regex fallback branches

Test suites: test/api/messages-test.js 100 passing, full API suite 681 passing, protocol and unit suites 385 passing.

Note, out of scope

lib/handlers/on-search.js:71 is the third $text producer in the repo. It hoists an IMAP TEXT/BODY term to the root of the query on the strength of a comment saying fulltext cannot live inside $or. That path was checked against MongoDB and is safe as written, but the comment is now only half true and the hoisting changes what an OR TEXT ... search matches. Worth a separate look.

@NickOvt
NickOvt requested a review from andris9 August 25, 2026 12:26
@NickOvt NickOvt self-assigned this Aug 25, 2026
@NickOvt
NickOvt marked this pull request as ready for review August 25, 2026 12:27
MongoDB resolves $text through the compound `fulltext` index, which is prefixed
with `user`, so a $text clause nested in an $or needs its own equality on
`user`, and every sibling branch of that $or has to be index backed. Neither
held, so any search mixing a header keyword with a fulltext term under OR
returned a 500.

applyTextIndexPrefix stamps the user id onto every branch of every $or that
wraps a $text clause. The filter already requires `user` at the top level, so
the repeated equality does not change what it matches. Both filter builders
call it, so the or.* API params are covered as well, not only the q syntax.

MongoDB also accepts a single $text expression per query, and the builder
emitted two whenever text terms landed in different OR branches. Terms that
cannot claim the one slot now fall back to the regex matching quoted phrases
already use, keeping AND semantics for a merged AND term and keeping negated
terms excluding rather than widening the match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0179oqSvLfdTeNBTsSPVmp4m
@andris9 andris9 changed the title fix(api-search): ZMS-94: Fix search in search-query fix(api-search): ZMS-94: keep $text queries plannable inside OR branches Aug 25, 2026

@andris9 andris9 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. Verified the reported query against MongoDB 7 with the real index set: it failed on the original commit and plans as TEXT_MATCH now.

The user prefix alone was necessary but not sufficient, so I pushed a follow-up commit:

  • the prefix only made the query plannable when searchable=1 also put a top level mailbox predicate in place. Without it, and from lib/tasks/search-apply.js which never sets it, the same query still returned 500. Stamping user onto every branch of the wrapping $or fixes it for every shape, since each branch then has an index to scan.
  • the same defect existed untouched in lib/prepare-search-filter.js (the or.* API params). Both builders now share one helper instead of hand rolling the rule.
  • q=from:a alpha OR to:b beta emitted two $text expressions and returned 500 regardless of searchable. Only one survives now, the rest fall back to the regex path used for quoted phrases.

Tests: the new API test now seeds messages and asserts on content rather than just a 200, and covers the path without searchable=1. Both new API tests fail on the original commit.

Not merging, leaving that to you.

@NickOvt
NickOvt merged commit a7ba203 into master Aug 26, 2026
6 checks passed
@NickOvt
NickOvt deleted the ZMS-94 branch August 26, 2026 07:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants