From 4cc15196da8d0af3d51e4822869d2c07b96840a8 Mon Sep 17 00:00:00 2001 From: Ivars Belovs Date: Tue, 30 Jun 2026 16:28:55 +0300 Subject: [PATCH 1/3] Fix flag handling: keywords, color names, and $labelN support update_mail_message_flags forced every flag through to_sym, so net/imap rendered non-system flags as backslash system flags (e.g. \YELLOW), which servers reject. Now only the six real IMAP system flags become symbols; everything else is sent as a bare keyword atom. Also add friendly color-name mapping (red/orange/green/blue/purple -> $labelN) while still accepting raw $labelN keywords unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tools/update_mail_message_flags_tool.rb | 33 ++++++++++++-- .../update_mail_message_flags_tool_spec.rb | 43 +++++++++++++++++-- 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/lib/mail_mcp/tools/update_mail_message_flags_tool.rb b/lib/mail_mcp/tools/update_mail_message_flags_tool.rb index f64b88f..b074a61 100644 --- a/lib/mail_mcp/tools/update_mail_message_flags_tool.rb +++ b/lib/mail_mcp/tools/update_mail_message_flags_tool.rb @@ -10,25 +10,52 @@ class UpdateMailMessageFlagsTool < Tool open_world_hint: true ) + # IMAP allows only these six backslash-prefixed system flags; anything else + # must be sent as a keyword (a bare atom with no leading backslash). + SYSTEM_FLAGS = %w[Seen Answered Flagged Deleted Draft Recent].freeze + + # Friendly color names map to Mozilla/Thunderbird $labelN keywords, the + # de-facto cross-client convention for colored flags. + COLOR_KEYWORDS = { + "red" => "$label1", + "orange" => "$label2", + "green" => "$label3", + "blue" => "$label4", + "purple" => "$label5" + }.freeze + input_schema( type: "object", properties: { folder: { type: "string" }, uid: { type: "integer" }, add: { type: "array", items: { type: "string" }, - description: "Flags to add, e.g. ['\\\\Seen', '\\\\Flagged']" }, + description: "Flags to add. System flags: '\\\\Seen', '\\\\Answered', " \ + "'\\\\Flagged', '\\\\Deleted', '\\\\Draft'. Color names " \ + "('red', 'orange', 'green', 'blue', 'purple') map to " \ + "$labelN keywords. Any other value is sent as a custom keyword." }, remove: { type: "array", items: { type: "string" }, description: "Flags to remove" } }, required: %w[folder uid] ) def self.call(folder:, uid:, server_context:, add: [], remove: []) - add_flags = add.map(&:to_sym) - remove_flags = remove.map(&:to_sym) + add_flags = add.map { |f| normalize_flag(f) } + remove_flags = remove.map { |f| normalize_flag(f) } ImapClient.connect(server_context.imap_config) do |c| c.update_flags(folder: folder, uid: uid, add: add_flags, remove: remove_flags) end MCP::Tool::Response.new([{ type: "text", text: "Flags updated for message #{uid}" }]) end + + # System flags become Symbols (net/imap renders them with a leading + # backslash). Custom keywords stay Strings (rendered as bare atoms). + # Friendly color names are translated to their $labelN keyword first. + def self.normalize_flag(flag) + name = flag.to_s.delete_prefix("\\") + return COLOR_KEYWORDS.fetch(name.downcase) if COLOR_KEYWORDS.key?(name.downcase) + + SYSTEM_FLAGS.include?(name) ? name.to_sym : name + end end end diff --git a/spec/mail_mcp/tools/update_mail_message_flags_tool_spec.rb b/spec/mail_mcp/tools/update_mail_message_flags_tool_spec.rb index e6f1419..6596dbe 100644 --- a/spec/mail_mcp/tools/update_mail_message_flags_tool_spec.rb +++ b/spec/mail_mcp/tools/update_mail_message_flags_tool_spec.rb @@ -13,17 +13,52 @@ allow(MailMCP::ImapClient).to receive(:connect).and_yield(imap_client) end - it "adds flags as symbols" do + it "adds system flags as symbols (so net/imap renders them with a backslash)" do described_class.call(folder: "INBOX", uid: 5, add: ["\\Seen", "\\Flagged"], server_context: context) expect(imap_client).to have_received(:update_flags).with( - folder: "INBOX", uid: 5, add: %i[\\Seen \\Flagged], remove: [] + folder: "INBOX", uid: 5, add: %i[Seen Flagged], remove: [] ) end - it "removes flags as symbols" do + it "removes system flags as symbols" do described_class.call(folder: "INBOX", uid: 5, remove: ["\\Seen"], server_context: context) expect(imap_client).to have_received(:update_flags).with( - hash_including(remove: [:"\\Seen"]) + hash_including(remove: [:Seen]) + ) + end + + it "passes custom keywords (e.g. colors) as strings, not system-flag symbols" do + described_class.call(folder: "INBOX", uid: 5, add: ["YELLOW", "$label1"], server_context: context) + expect(imap_client).to have_received(:update_flags).with( + hash_including(add: ["YELLOW", "$label1"]) + ) + end + + it "treats system flag names without a backslash as system flags" do + described_class.call(folder: "INBOX", uid: 5, add: ["Seen"], server_context: context) + expect(imap_client).to have_received(:update_flags).with( + hash_including(add: [:Seen]) + ) + end + + it "maps friendly color names to $labelN keywords" do + described_class.call(folder: "INBOX", uid: 5, add: %w[red blue], server_context: context) + expect(imap_client).to have_received(:update_flags).with( + hash_including(add: ["$label1", "$label4"]) + ) + end + + it "maps color names case-insensitively, including on remove" do + described_class.call(folder: "INBOX", uid: 5, remove: ["GREEN"], server_context: context) + expect(imap_client).to have_received(:update_flags).with( + hash_including(remove: ["$label3"]) + ) + end + + it "passes raw $labelN keywords through unchanged" do + described_class.call(folder: "INBOX", uid: 5, add: %w[$label1 $label5], server_context: context) + expect(imap_client).to have_received(:update_flags).with( + hash_including(add: %w[$label1 $label5]) ) end From 9492f7079759d8d54604ce3eba5ca4dbcf598125 Mon Sep 17 00:00:00 2001 From: Ivars Belovs Date: Tue, 30 Jun 2026 16:32:10 +0300 Subject: [PATCH 2/3] Fix rubocop offence --- lib/mail_mcp/tools/update_mail_message_flags_tool.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/mail_mcp/tools/update_mail_message_flags_tool.rb b/lib/mail_mcp/tools/update_mail_message_flags_tool.rb index b074a61..531ec72 100644 --- a/lib/mail_mcp/tools/update_mail_message_flags_tool.rb +++ b/lib/mail_mcp/tools/update_mail_message_flags_tool.rb @@ -17,10 +17,10 @@ class UpdateMailMessageFlagsTool < Tool # Friendly color names map to Mozilla/Thunderbird $labelN keywords, the # de-facto cross-client convention for colored flags. COLOR_KEYWORDS = { - "red" => "$label1", + "red" => "$label1", "orange" => "$label2", - "green" => "$label3", - "blue" => "$label4", + "green" => "$label3", + "blue" => "$label4", "purple" => "$label5" }.freeze From e8c05a59b981eef33ab537f02db91fcba979bf26 Mon Sep 17 00:00:00 2001 From: Ivars Belovs Date: Tue, 30 Jun 2026 16:38:35 +0300 Subject: [PATCH 3/3] Address PR review: case-insensitive system flags, clarify \Recent - Match system flags case-insensitively (RFC 3501) and normalize to canonical capitalization, so "seen"/"\SEEN" still set :Seen rather than being sent as a bare keyword (regression vs old to_sym path). - Clarify in the schema that \Recent is recognized but not client-settable. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/mail_mcp/tools/update_mail_message_flags_tool.rb | 10 +++++++--- .../tools/update_mail_message_flags_tool_spec.rb | 7 +++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/mail_mcp/tools/update_mail_message_flags_tool.rb b/lib/mail_mcp/tools/update_mail_message_flags_tool.rb index 531ec72..db844cc 100644 --- a/lib/mail_mcp/tools/update_mail_message_flags_tool.rb +++ b/lib/mail_mcp/tools/update_mail_message_flags_tool.rb @@ -30,8 +30,9 @@ class UpdateMailMessageFlagsTool < Tool folder: { type: "string" }, uid: { type: "integer" }, add: { type: "array", items: { type: "string" }, - description: "Flags to add. System flags: '\\\\Seen', '\\\\Answered', " \ - "'\\\\Flagged', '\\\\Deleted', '\\\\Draft'. Color names " \ + description: "Flags to add. Settable system flags: '\\\\Seen', '\\\\Answered', " \ + "'\\\\Flagged', '\\\\Deleted', '\\\\Draft' (\\\\Recent is recognized " \ + "but cannot be set by clients). Color names " \ "('red', 'orange', 'green', 'blue', 'purple') map to " \ "$labelN keywords. Any other value is sent as a custom keyword." }, remove: { type: "array", items: { type: "string" }, description: "Flags to remove" } @@ -55,7 +56,10 @@ def self.normalize_flag(flag) name = flag.to_s.delete_prefix("\\") return COLOR_KEYWORDS.fetch(name.downcase) if COLOR_KEYWORDS.key?(name.downcase) - SYSTEM_FLAGS.include?(name) ? name.to_sym : name + # System flags are case-insensitive per RFC 3501; normalize to the + # canonical capitalization so e.g. "seen" / "\\SEEN" still set :Seen. + system_flag = SYSTEM_FLAGS.find { |f| f.casecmp?(name) } + system_flag ? system_flag.to_sym : name end end end diff --git a/spec/mail_mcp/tools/update_mail_message_flags_tool_spec.rb b/spec/mail_mcp/tools/update_mail_message_flags_tool_spec.rb index 6596dbe..eaff7aa 100644 --- a/spec/mail_mcp/tools/update_mail_message_flags_tool_spec.rb +++ b/spec/mail_mcp/tools/update_mail_message_flags_tool_spec.rb @@ -41,6 +41,13 @@ ) end + it "matches system flags case-insensitively and normalizes capitalization" do + described_class.call(folder: "INBOX", uid: 5, add: ["\\seen", "FLAGGED"], server_context: context) + expect(imap_client).to have_received(:update_flags).with( + hash_including(add: %i[Seen Flagged]) + ) + end + it "maps friendly color names to $labelN keywords" do described_class.call(folder: "INBOX", uid: 5, add: %w[red blue], server_context: context) expect(imap_client).to have_received(:update_flags).with(