Repository navigation
Search for the words, not the exact string - #10
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
Searching a mailbox for a contact's name came back empty. The signature carries a second space between two of its words:
The whole query went out as one IMAP
TEXTkey, which is a substring match over the raw message — soIris van 't Kloosteris genuinely not in there. The same literal would equally have missed a header fold between the words, a curly apostrophe, or a sender who writesKlooster, 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).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 asJos), 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_termsonSearchResultsaying 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
parsedis an additive keyword onMailProvider.search, offered via signature inspection. A backend written before it keeps its rawqueryand answers exactly as it used to —tests/test_mail_search.pypins that with the conftest fixture's old-signature provider.Tests
tests/test_search_query.py— folding, the grammar, verification, the laddertests/test_mail_search.py— the IMAP keys that leave, and the widening policy end to endmake test205 passed ·make lintclean. The integration layer needs Docker, which this session had no daemon for, so those three run first in CI.Generated by Claude Code