From e993a569a03983f2fe8e2dcf0afcc1d6e996a656 Mon Sep 17 00:00:00 2001 From: Ivars Belovs Date: Tue, 25 Aug 2026 12:39:56 +0300 Subject: [PATCH] Return UIDs from search_mail_messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit search_messages issued IMAP SEARCH, which returns message sequence numbers. The tool serialised them under a `uids` key, but every tool a caller would chain into — get_mail_message, delete_mail_message, move_mail_message, update_mail_message_flags — issues UID commands (uid_fetch, uid_store, uid_move). Search results therefore addressed the wrong messages. The two agree only while a folder has never been expunged, since sequence numbers are renumbered on every expunge while UIDs are stable. In a fresh test mailbox seq == UID, so the mismatch looks like it works and then silently drifts: get_mail_message returns a different message than the one matched, and delete/move act on it. Switch to uid_search, matching list_messages, which already uses it. The specs stubbed :search and asserted the return value passed through, so the suite pinned the buggy contract rather than catching it — they now stub :uid_search. Net::IMAP is stubbed with instance_spy, a verifying double, so these also check uid_search exists and accepts the array-of-criteria form. No release included; production installs via `gem install mail_mcp -v X`, so this needs a version bump to land. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- lib/mail_mcp/imap_client.rb | 5 ++++- lib/mail_mcp/tools/search_mail_messages_tool.rb | 3 ++- spec/mail_mcp/imap_client_spec.rb | 6 +++--- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6f996be..757aae8 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ bundle exec bin/mail_mcp generate \ | `list_mailboxes` | — | List all IMAP folders | | `list_mail_messages` | `folder`, `page`, `per_page` | List messages with pagination | | `get_mail_message` | `folder`, `uid` | Fetch full message; attachments uploaded to S3 and returned as presigned URLs | -| `search_mail_messages` | `folder`, `query` | Raw IMAP SEARCH criteria, e.g. `UNSEEN` or `FROM alice@example.com SINCE 01-Jan-2025` | +| `search_mail_messages` | `folder`, `query` | Raw IMAP SEARCH criteria, e.g. `UNSEEN` or `FROM alice@example.com SINCE 01-Jan-2025`; returns UIDs | | `send_mail_message` | `to`, `subject`, `text_body`, `cc`, `bcc`, `html_body`, `attachment_urls`, `folder` | Send via SMTP and append to the Sent folder via IMAP; attachments fetched from S3 presigned URLs | | `create_draft_mail_message` | `to`, `subject`, `text_body`, `cc`, `bcc`, `html_body`, `attachment_urls`, `folder` | Append to Drafts via IMAP APPEND; attachments fetched from S3 presigned URLs | | `delete_mail_message` | `folder`, `uid` | Mark `\Deleted` + EXPUNGE | diff --git a/lib/mail_mcp/imap_client.rb b/lib/mail_mcp/imap_client.rb index 9b12886..d145741 100644 --- a/lib/mail_mcp/imap_client.rb +++ b/lib/mail_mcp/imap_client.rb @@ -90,7 +90,10 @@ def get_message(folder:, uid:) def search_messages(folder:, query:) MailMCP.logger.info { "IMAP search_messages folder=#{folder.inspect} query=#{query.inspect}" } @imap.examine(folder) - results = @imap.search(query.split) + # UID SEARCH, not SEARCH: the ids returned here are fed back into + # get_message/delete_message/move_message/update_flags, which all issue + # UID commands. Sequence numbers would only match until the first expunge. + results = @imap.uid_search(query.split) MailMCP.logger.debug { "IMAP search_messages matched=#{results.size}" } results end diff --git a/lib/mail_mcp/tools/search_mail_messages_tool.rb b/lib/mail_mcp/tools/search_mail_messages_tool.rb index 1eeaa4d..59af44a 100644 --- a/lib/mail_mcp/tools/search_mail_messages_tool.rb +++ b/lib/mail_mcp/tools/search_mail_messages_tool.rb @@ -1,7 +1,8 @@ module MailMCP class SearchMailMessagesTool < Tool tool_name "search_mail_messages" - description "Search messages in an IMAP folder using raw IMAP SEARCH criteria" + description "Search messages in an IMAP folder using raw IMAP SEARCH criteria. " \ + "Returns message UIDs usable with get_mail_message" annotations( title: "Search Mail Messages", read_only_hint: true, diff --git a/spec/mail_mcp/imap_client_spec.rb b/spec/mail_mcp/imap_client_spec.rb index d2b3985..6837bcc 100644 --- a/spec/mail_mcp/imap_client_spec.rb +++ b/spec/mail_mcp/imap_client_spec.rb @@ -108,15 +108,15 @@ end describe "#search_messages" do - it "passes raw query string to IMAP SEARCH" do - allow(imap).to receive(:search).with(["UNSEEN"]).and_return([1, 2, 3]) + it "passes raw query string to IMAP UID SEARCH" do + allow(imap).to receive(:uid_search).with(["UNSEEN"]).and_return([1, 2, 3]) client = described_class.new(imap) result = client.search_messages(folder: "INBOX", query: "UNSEEN") expect(result).to eq([1, 2, 3]) end it "supports multi-word criteria" do - allow(imap).to receive(:search).with(["FROM", "alice@example.com"]).and_return([5]) + allow(imap).to receive(:uid_search).with(["FROM", "alice@example.com"]).and_return([5]) client = described_class.new(imap) result = client.search_messages(folder: "INBOX", query: "FROM alice@example.com") expect(result).to eq([5])