Skip to content

Search for the words, not the exact string - #10

Merged
rutgerhofste merged 2 commits into
mainfrom
claude/mcp-mail-search-improve-pdrdsu
Aug 12, 2026
Merged

rutgerhofste merged 2 commits into
mainfrom
claude/mcp-mail-search-improve-pdrdsu

Conversation

@rutgerhofste

Copy link
Copy Markdown
Member

The bug

Searching a mailbox for a contact's name came back empty. The signature carries a second space between two of its words:

Iris  van 't Klooster

The whole query went out as one IMAP TEXT key, which is a substring match over the raw message — so Iris van 't Klooster is genuinely not in there. The same literal would equally have missed a header fold between the words, a curly apostrophe, or a sender who writes Klooster, Iris.

Worse, what happened instead was undefined. RFC 3501 explicitly lets a server "implement flexible matching" for TEXT. Checked against three real accounts: one IMAP host word-matches and ignores order, another does strict substring, and Graph runs KQL keywords. The same tool meant three different things depending on who answered — the divergence the attachment and meeting seams are already held against.

The fix

A backend-neutral query layer, squirrel_mcp/search_query.py, parsed once and compiled by each backend (IMAP into search keys here; the admin package's Graph provider into KQL, in its companion PR).

iris klooster           both words, anywhere, any order   ← the default now
"fiscal localization"   those words together, in order
from:iris               the sender, not every mention
to: cc: subject: body:  the other fields
-newsletter             must not appear
invoice OR factuur      either one
has:attachment          only messages carrying a file

Unquoted words AND as separate keys rather than forming a phrase — Gmail's and Outlook's convention, which every model prompting them already assumes, and the whole fix for the spacing bug. Case, accents and apostrophe flavours fold.

Three rules keep the backends honest

The compiled query is always at least as broad as what was asked. No server will fold accents in a mailbox for us and neither spelling of a name finds the other, so a term that cannot be sent faithfully is widened (José leaves as Jos), never narrowed.

Anything a summary can prove is verified exactly, taking that breadth back — sender, recipients, subject, attachment flag. What a summary cannot carry (the body, cc) stays the server's word, because a 200-character preview of a body that may be megabytes refutes nothing, and filtering on it would drop the matches this exists to find.

Widening is reported, never silent. A query matching nothing widens rather than returning an empty page that reads as "you have no mail from her": one term given up at a time, with matched / dropped_terms on SearchResult saying which.

The ladder tries each term in turn rather than guessing once. Dropping only the least distinctive term looks right and is not: the absent word is usually the rarest one, so a single guess keeps the missing word and stays empty. ORing everything together is deliberately not on the ladder — it always "works" and never informs.

Compatibility

parsed is an additive keyword on MailProvider.search, offered via signature inspection. A backend written before it keeps its raw query and answers exactly as it used to — tests/test_mail_search.py pins that with the conftest fixture's old-signature provider.

Tests

  • tests/test_search_query.py — folding, the grammar, verification, the ladder
  • tests/test_mail_search.py — the IMAP keys that leave, and the widening policy end to end
  • GreenMail e2e — a real IMAP server finds the awkwardly-spaced name, honours a scoped term and an exclusion

make test 205 passed · make lint clean. The integration layer needs Docker, which this session had no daemon for, so those three run first in CI.


Generated by Claude Code

rutgerhofste and others added 2 commits August 12, 2026 12:33
Searching a mailbox for a contact's name came back empty because the
signature carries a second space between two of its words. The whole
query went out as one IMAP TEXT key, which is a substring match over the
raw message, so "Iris van 't Klooster" is genuinely not in a message
reading "Iris  van 't Klooster" -- and would equally have missed a
header fold, a curly apostrophe, or a sender who writes "Klooster, Iris".

Worse, what happened instead was undefined. RFC 3501 lets a server
"implement flexible matching" for TEXT, so one host word-matches and
ignores order, the next does strict substring, and Graph runs KQL
keywords. The same tool meant three things depending on who answered.

So the query is parsed once, in the package both backends share, and
each compiles the same object: unquoted words AND as separate keys (a
phrase is what quoting is for), from:/to:/cc:/subject:/body: scope a
word to a header the server can answer on, -word excludes, OR offers a
choice, has:attachment narrows. Case, accents and apostrophes fold.

Three rules keep the two backends honest. The compiled query is always
at least as broad as what was asked, since no server will fold accents
for us and sending one spelling of a name misses the other. Whatever a
summary can prove -- sender, recipients, subject, attachments -- is then
verified here exactly, taking that breadth back; what it cannot prove
(a body we have 200 characters of) stays the server's word. And a query
that matches nothing widens instead of returning an empty page that
reads as "you have no mail from her": one term given up at a time, each
tried in turn because the absent word is usually the rarest one, with
matched and dropped_terms reporting which.

parsed is additive on MailProvider.search and offered by signature
inspection, so a backend written before it keeps working unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENzBvvtAWS8VNTFqqb6TXs
A negation inside an OR was compiled away. `a OR -b` emitted OR(a, b) --
the exclusion silently became an inclusion, which is the worst direction
for that mistake to go. Both compilers now carry per-term negation
through an alternation, and a wholly-negated clause keeps its single
enclosing NOT so `-a OR -b` still reads as "neither", which is what it
means and what verify() checks.

Widening ran against a backend that cannot see it. The ladder weakens
`parsed` while `raw` stays what the caller typed, so an old-signature
provider was asked the identical question once per rung and answered
nothing every time. It is now guarded on the provider reading `parsed`.

And that guard exposed the capability test itself: a **kwargs signature
accepts the argument without raising and then ignores it, so treating it
as yes brought back exactly the repeated-question case. The parameter
has to be named.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENzBvvtAWS8VNTFqqb6TXs
@rutgerhofste
rutgerhofste marked this pull request as ready for review August 12, 2026 12:52
@rutgerhofste
rutgerhofste merged commit a96a1ee into main Aug 12, 2026
3 checks passed
@rutgerhofste
rutgerhofste deleted the claude/mcp-mail-search-improve-pdrdsu branch August 13, 2026 14:29
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.

1 participant