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..db844cc 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,56 @@ 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. 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" } }, 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 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 e6f1419..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 @@ -13,17 +13,59 @@ 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 "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( + 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