mail_mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +174 -0
- data/bin/mail_mcp +113 -0
- data/config/puma.rb +8 -0
- data/config.ru +8 -0
- data/lib/mail_mcp/app.rb +281 -0
- data/lib/mail_mcp/attachment_store.rb +32 -0
- data/lib/mail_mcp/credential_context.rb +5 -0
- data/lib/mail_mcp/imap_client.rb +151 -0
- data/lib/mail_mcp/jwt_service.rb +126 -0
- data/lib/mail_mcp/mail_builder.rb +42 -0
- data/lib/mail_mcp/pkce.rb +19 -0
- data/lib/mail_mcp/smtp_client.rb +28 -0
- data/lib/mail_mcp/tool.rb +6 -0
- data/lib/mail_mcp/tools/create_draft_mail_message_tool.rb +42 -0
- data/lib/mail_mcp/tools/delete_mail_message_tool.rb +27 -0
- data/lib/mail_mcp/tools/get_mail_message_tool.rb +29 -0
- data/lib/mail_mcp/tools/list_mail_messages_tool.rb +30 -0
- data/lib/mail_mcp/tools/list_mailboxes_tool.rb +18 -0
- data/lib/mail_mcp/tools/move_mail_message_tool.rb +30 -0
- data/lib/mail_mcp/tools/search_mail_messages_tool.rb +31 -0
- data/lib/mail_mcp/tools/send_mail_message_tool.rb +43 -0
- data/lib/mail_mcp/tools/update_mail_message_flags_tool.rb +34 -0
- data/lib/mail_mcp/version.rb +3 -0
- data/lib/mail_mcp.rb +26 -0
- data/views/login.erb +128 -0
- metadata +207 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
require "net/imap"
|
|
2
|
+
|
|
3
|
+
module MailMCP
|
|
4
|
+
class ImapClient
|
|
5
|
+
class AuthError < StandardError; end
|
|
6
|
+
class ConnectionError < StandardError; end
|
|
7
|
+
|
|
8
|
+
attr_reader :imap
|
|
9
|
+
|
|
10
|
+
def initialize(imap)
|
|
11
|
+
@imap = imap
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.validate!(config)
|
|
15
|
+
conn = Net::IMAP.new(config[:host], port: config[:port], ssl: config[:ssl])
|
|
16
|
+
conn.login(config[:username], config[:password])
|
|
17
|
+
conn.logout
|
|
18
|
+
conn.disconnect
|
|
19
|
+
rescue Net::IMAP::NoResponseError, Net::IMAP::BadResponseError => e
|
|
20
|
+
raise AuthError, "IMAP authentication failed: #{e.message}"
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
raise ConnectionError, "IMAP connection failed: #{e.message}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.connect(config)
|
|
26
|
+
conn = Net::IMAP.new(config[:host], port: config[:port], ssl: config[:ssl])
|
|
27
|
+
conn.login(config[:username], config[:password])
|
|
28
|
+
client = new(conn)
|
|
29
|
+
yield client
|
|
30
|
+
rescue Net::IMAP::NoResponseError, Net::IMAP::BadResponseError => e
|
|
31
|
+
raise AuthError, "IMAP authentication failed: #{e.message}"
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
raise ConnectionError, "IMAP connection failed: #{e.message}"
|
|
34
|
+
ensure
|
|
35
|
+
begin
|
|
36
|
+
conn&.logout
|
|
37
|
+
rescue StandardError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
begin
|
|
41
|
+
conn&.disconnect
|
|
42
|
+
rescue StandardError
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def list_mailboxes
|
|
48
|
+
@imap.list("", "*").map(&:name)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def list_messages(folder:, page: 1, per_page: 20)
|
|
52
|
+
@imap.examine(folder)
|
|
53
|
+
uids = @imap.uid_search(["ALL"]).reverse
|
|
54
|
+
total = uids.length
|
|
55
|
+
offset = (page - 1) * per_page
|
|
56
|
+
page_uids = uids[offset, per_page] || []
|
|
57
|
+
return { messages: [], total: total } if page_uids.empty?
|
|
58
|
+
|
|
59
|
+
envelopes = @imap.uid_fetch(page_uids, ["ENVELOPE", "FLAGS", "RFC822.SIZE"])
|
|
60
|
+
messages = (envelopes || []).map { |msg| format_envelope(msg) }
|
|
61
|
+
{ messages: messages, total: total, page: page, per_page: per_page }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def get_message(folder:, uid:)
|
|
65
|
+
@imap.examine(folder)
|
|
66
|
+
data = @imap.uid_fetch([uid.to_i], %w[RFC822 FLAGS]).first
|
|
67
|
+
return nil unless data
|
|
68
|
+
|
|
69
|
+
raw = data.attr["RFC822"]
|
|
70
|
+
flags = data.attr["FLAGS"]
|
|
71
|
+
parsed = Mail.new(raw)
|
|
72
|
+
attachments = extract_attachments(parsed)
|
|
73
|
+
{
|
|
74
|
+
uid: uid,
|
|
75
|
+
subject: parsed.subject,
|
|
76
|
+
from: parsed.from,
|
|
77
|
+
to: parsed.to,
|
|
78
|
+
cc: parsed.cc,
|
|
79
|
+
date: parsed.date&.iso8601,
|
|
80
|
+
text_body: parsed.text_part&.decoded,
|
|
81
|
+
html_body: parsed.html_part&.decoded,
|
|
82
|
+
flags: flags,
|
|
83
|
+
attachments: attachments
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def search_messages(folder:, query:)
|
|
88
|
+
@imap.examine(folder)
|
|
89
|
+
@imap.search(query.split)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def delete_message(folder:, uid:)
|
|
93
|
+
@imap.select(folder)
|
|
94
|
+
@imap.uid_store(uid.to_i, "+FLAGS", [:Deleted])
|
|
95
|
+
@imap.expunge
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def move_message(folder:, uid:, destination:)
|
|
99
|
+
@imap.select(folder)
|
|
100
|
+
if @imap.capability.include?("MOVE")
|
|
101
|
+
@imap.uid_move(uid.to_i, destination)
|
|
102
|
+
else
|
|
103
|
+
@imap.uid_copy(uid.to_i, destination)
|
|
104
|
+
@imap.uid_store(uid.to_i, "+FLAGS", [:Deleted])
|
|
105
|
+
@imap.expunge
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def update_flags(folder:, uid:, add: [], remove: [])
|
|
110
|
+
@imap.select(folder)
|
|
111
|
+
@imap.uid_store(uid.to_i, "+FLAGS", add) unless add.empty?
|
|
112
|
+
@imap.uid_store(uid.to_i, "-FLAGS", remove) unless remove.empty?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def append_message(folder:, raw_message:)
|
|
116
|
+
@imap.append(folder, raw_message, [:Draft], Time.now)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def format_envelope(msg)
|
|
122
|
+
env = msg.attr["ENVELOPE"]
|
|
123
|
+
{
|
|
124
|
+
uid: msg.attr["UID"],
|
|
125
|
+
subject: env.subject,
|
|
126
|
+
from: format_addresses(env.from),
|
|
127
|
+
to: format_addresses(env.to),
|
|
128
|
+
date: env.date,
|
|
129
|
+
size: msg.attr["RFC822.SIZE"],
|
|
130
|
+
flags: msg.attr["FLAGS"]
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def format_addresses(addrs)
|
|
135
|
+
return [] unless addrs
|
|
136
|
+
|
|
137
|
+
addrs.map { |a| "#{a.name} <#{a.mailbox}@#{a.host}>" }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def extract_attachments(mail)
|
|
141
|
+
mail.attachments.map do |att|
|
|
142
|
+
url = AttachmentStore.upload(
|
|
143
|
+
content: att.decoded,
|
|
144
|
+
filename: att.filename || "attachment",
|
|
145
|
+
content_type: att.content_type
|
|
146
|
+
)
|
|
147
|
+
{ filename: att.filename, content_type: att.content_type, size: att.decoded.bytesize, url: url }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
require "json/jwt"
|
|
2
|
+
|
|
3
|
+
module MailMCP
|
|
4
|
+
module JwtService
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
DEFAULT_EXPIRY = 8 * 3600
|
|
8
|
+
DEFAULT_REFRESH_EXPIRY = 30 * 24 * 3600
|
|
9
|
+
|
|
10
|
+
CRED_KEYS = %w[
|
|
11
|
+
imap_host imap_port imap_ssl imap_username imap_password
|
|
12
|
+
smtp_host smtp_port smtp_ssl smtp_username smtp_password
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
# Access token — JWE (dir/A256GCM), credentials embedded directly in payload
|
|
16
|
+
def self.issue(creds, expires_in: DEFAULT_EXPIRY)
|
|
17
|
+
issue_jwe(creds.merge("typ" => "access"), expires_in: expires_in)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.verify(token)
|
|
21
|
+
payload = decode_jwe(token)
|
|
22
|
+
raise Error, "Not an access token" unless payload["typ"] == "access"
|
|
23
|
+
|
|
24
|
+
payload.slice(*CRED_KEYS)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Refresh token — JWE, longer-lived, carries same credential payload
|
|
28
|
+
def self.issue_refresh(creds, expires_in: DEFAULT_REFRESH_EXPIRY)
|
|
29
|
+
issue_jwe(creds.merge("typ" => "refresh"), expires_in: expires_in)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.verify_refresh(token)
|
|
33
|
+
payload = decode_jwe(token)
|
|
34
|
+
raise Error, "Not a refresh token" unless payload["typ"] == "refresh"
|
|
35
|
+
|
|
36
|
+
payload.slice(*CRED_KEYS)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Authorization code — short-lived JWE carrying creds + PKCE state (stateless PKCE)
|
|
40
|
+
CODE_EXPIRY = 300 # 5 minutes
|
|
41
|
+
|
|
42
|
+
def self.issue_code(creds:, code_challenge:, redirect_uri:, client_id:)
|
|
43
|
+
issue_jwe(
|
|
44
|
+
creds.merge(
|
|
45
|
+
"typ" => "code",
|
|
46
|
+
"code_challenge" => code_challenge,
|
|
47
|
+
"redirect_uri" => redirect_uri,
|
|
48
|
+
"client_id" => client_id
|
|
49
|
+
),
|
|
50
|
+
expires_in: CODE_EXPIRY
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.verify_code(token)
|
|
55
|
+
payload = decode_jwe(token)
|
|
56
|
+
raise Error, "Not an authorization code" unless payload["typ"] == "code"
|
|
57
|
+
|
|
58
|
+
payload
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Client ID token — JWE, no expiry, carries imap/smtp config + client_secret
|
|
62
|
+
def self.issue_client_id(imap_host:, imap_port:, imap_ssl:, smtp_host:, smtp_port:, smtp_ssl:, client_secret:)
|
|
63
|
+
payload = JSON.generate(
|
|
64
|
+
iss: ENV.fetch("BASE_URL"),
|
|
65
|
+
aud: ENV.fetch("BASE_URL"),
|
|
66
|
+
typ: "client_id",
|
|
67
|
+
imap_host: imap_host,
|
|
68
|
+
imap_port: imap_port.to_i,
|
|
69
|
+
imap_ssl: imap_ssl,
|
|
70
|
+
smtp_host: smtp_host,
|
|
71
|
+
smtp_port: smtp_port.to_i,
|
|
72
|
+
smtp_ssl: smtp_ssl,
|
|
73
|
+
cs: client_secret
|
|
74
|
+
)
|
|
75
|
+
encrypt_jwe(payload)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.decode_client_id(token)
|
|
79
|
+
payload = decode_jwe(token, verify_exp: false)
|
|
80
|
+
raise Error, "Not a client_id token" unless payload["typ"] == "client_id"
|
|
81
|
+
|
|
82
|
+
payload
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.issue_jwe(payload_hash, expires_in:)
|
|
86
|
+
now = Time.now.to_i
|
|
87
|
+
payload = JSON.generate(
|
|
88
|
+
payload_hash.merge(
|
|
89
|
+
"iss" => ENV.fetch("BASE_URL"),
|
|
90
|
+
"aud" => ENV.fetch("BASE_URL"),
|
|
91
|
+
"iat" => now,
|
|
92
|
+
"exp" => now + expires_in
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
encrypt_jwe(payload)
|
|
96
|
+
end
|
|
97
|
+
private_class_method :issue_jwe
|
|
98
|
+
|
|
99
|
+
def self.encrypt_jwe(payload_json)
|
|
100
|
+
jwe = JSON::JWE.new(payload_json)
|
|
101
|
+
jwe.alg = :dir
|
|
102
|
+
jwe.enc = :A256GCM
|
|
103
|
+
jwe.encrypt!(encryption_key)
|
|
104
|
+
jwe.to_s
|
|
105
|
+
end
|
|
106
|
+
private_class_method :encrypt_jwe
|
|
107
|
+
|
|
108
|
+
def self.decode_jwe(token, verify_exp: true)
|
|
109
|
+
jwe = JSON::JWE.decode(token, encryption_key)
|
|
110
|
+
payload = JSON.parse(jwe.plain_text)
|
|
111
|
+
raise Error, "Token expired" if verify_exp && payload["exp"].to_i < Time.now.to_i
|
|
112
|
+
raise Error, "Invalid issuer" if payload["iss"] != ENV.fetch("BASE_URL")
|
|
113
|
+
raise Error, "Invalid audience" if payload["aud"] != ENV.fetch("BASE_URL")
|
|
114
|
+
|
|
115
|
+
payload
|
|
116
|
+
rescue JSON::JWT::Exception => e
|
|
117
|
+
raise Error, e.message
|
|
118
|
+
end
|
|
119
|
+
private_class_method :decode_jwe
|
|
120
|
+
|
|
121
|
+
def self.encryption_key
|
|
122
|
+
Base64.strict_decode64(ENV.fetch("ENCRYPTION_KEY"))
|
|
123
|
+
end
|
|
124
|
+
private_class_method :encryption_key
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
require "mail"
|
|
2
|
+
require "open-uri"
|
|
3
|
+
|
|
4
|
+
module MailMCP
|
|
5
|
+
module MailBuilder
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def build(from:, to:, subject:, text_body:, cc: nil, bcc: nil, html_body: nil, attachment_urls: [])
|
|
9
|
+
mail = Mail.new
|
|
10
|
+
mail.from = from
|
|
11
|
+
mail.to = to
|
|
12
|
+
mail.subject = subject
|
|
13
|
+
mail.cc = cc if cc
|
|
14
|
+
mail.bcc = bcc if bcc
|
|
15
|
+
if html_body
|
|
16
|
+
mail.html_part = Mail::Part.new do
|
|
17
|
+
content_type "text/html
|
|
18
|
+
charset=UTF-8"
|
|
19
|
+
body html_body
|
|
20
|
+
end
|
|
21
|
+
mail.text_part = Mail::Part.new do
|
|
22
|
+
content_type "text/plain
|
|
23
|
+
charset=UTF-8"
|
|
24
|
+
body text_body
|
|
25
|
+
end
|
|
26
|
+
else
|
|
27
|
+
mail.body = text_body
|
|
28
|
+
end
|
|
29
|
+
attachment_urls.each { |url| attach_from_url(mail, url) }
|
|
30
|
+
mail
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def attach_from_url(mail, url)
|
|
34
|
+
URI.open(url) do |f|
|
|
35
|
+
filename = File.basename(URI.parse(url).path)
|
|
36
|
+
mail.attachments[filename] = { content: f.read, mime_type: f.content_type }
|
|
37
|
+
end
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
raise "Failed to fetch attachment from #{url}: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
|
|
4
|
+
module MailMCP
|
|
5
|
+
module Pkce
|
|
6
|
+
def self.challenge(verifier)
|
|
7
|
+
Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.valid?(verifier:, challenge:)
|
|
11
|
+
expected = challenge(verifier)
|
|
12
|
+
begin
|
|
13
|
+
ActiveSupport::SecurityUtils.secure_compare(expected, challenge)
|
|
14
|
+
rescue StandardError
|
|
15
|
+
(expected == challenge)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require "net/smtp"
|
|
2
|
+
|
|
3
|
+
module MailMCP
|
|
4
|
+
module SmtpClient
|
|
5
|
+
class ConnectionError < StandardError; end
|
|
6
|
+
|
|
7
|
+
def self.validate!(config)
|
|
8
|
+
smtp_open(config) { nil }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.send(config, mail)
|
|
12
|
+
smtp_open(config) do |s|
|
|
13
|
+
s.send_message(mail.to_s, mail.from.first, mail.to)
|
|
14
|
+
end
|
|
15
|
+
rescue Net::SMTPError, SocketError => e
|
|
16
|
+
raise ConnectionError, "SMTP send failed: #{e.message}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.smtp_open(config, &)
|
|
20
|
+
smtp = Net::SMTP.new(config[:host], config[:port])
|
|
21
|
+
smtp.enable_starttls_auto unless config[:ssl]
|
|
22
|
+
smtp.start(config[:host], config[:username], config[:password], :login, &)
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
raise ConnectionError, "SMTP connection failed: #{e.message}"
|
|
25
|
+
end
|
|
26
|
+
private_class_method :smtp_open
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module MailMCP
|
|
2
|
+
class CreateDraftMailMessageTool < Tool
|
|
3
|
+
tool_name "create_draft_mail_message"
|
|
4
|
+
description "Save an email as a draft to the Drafts folder via IMAP APPEND"
|
|
5
|
+
annotations(
|
|
6
|
+
title: "Create Draft Mail Message",
|
|
7
|
+
read_only_hint: false,
|
|
8
|
+
destructive_hint: false,
|
|
9
|
+
idempotent_hint: false,
|
|
10
|
+
open_world_hint: true
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
input_schema(
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
to: { type: "string" },
|
|
17
|
+
subject: { type: "string" },
|
|
18
|
+
text_body: { type: "string" },
|
|
19
|
+
cc: { type: "string" },
|
|
20
|
+
bcc: { type: "string" },
|
|
21
|
+
html_body: { type: "string" },
|
|
22
|
+
attachment_urls: { type: "array", items: { type: "string" },
|
|
23
|
+
description: "S3 presigned URLs to attach" },
|
|
24
|
+
folder: { type: "string", description: "Target folder (default: Drafts)", default: "Drafts" }
|
|
25
|
+
},
|
|
26
|
+
required: %w[to subject text_body]
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def self.call(to:, subject:, text_body:, server_context:, cc: nil, bcc: nil, html_body: nil,
|
|
30
|
+
attachment_urls: [], folder: "Drafts")
|
|
31
|
+
imap_config = server_context.imap_config
|
|
32
|
+
mail = MailBuilder.build(
|
|
33
|
+
from: imap_config[:username],
|
|
34
|
+
to: to, subject: subject, text_body: text_body,
|
|
35
|
+
cc: cc, bcc: bcc, html_body: html_body,
|
|
36
|
+
attachment_urls: attachment_urls
|
|
37
|
+
)
|
|
38
|
+
ImapClient.connect(imap_config) { |c| c.append_message(folder: folder, raw_message: mail.to_s) }
|
|
39
|
+
MCP::Tool::Response.new([{ type: "text", text: "Draft saved to #{folder}" }])
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module MailMCP
|
|
2
|
+
class DeleteMailMessageTool < Tool
|
|
3
|
+
tool_name "delete_mail_message"
|
|
4
|
+
description "Delete a message by UID (marks as deleted and expunges)"
|
|
5
|
+
annotations(
|
|
6
|
+
title: "Delete Mail Message",
|
|
7
|
+
read_only_hint: false,
|
|
8
|
+
destructive_hint: true,
|
|
9
|
+
idempotent_hint: false,
|
|
10
|
+
open_world_hint: true
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
input_schema(
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
folder: { type: "string" },
|
|
17
|
+
uid: { type: "integer" }
|
|
18
|
+
},
|
|
19
|
+
required: %w[folder uid]
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def self.call(folder:, uid:, server_context:)
|
|
23
|
+
ImapClient.connect(server_context.imap_config) { |c| c.delete_message(folder: folder, uid: uid) }
|
|
24
|
+
MCP::Tool::Response.new([{ type: "text", text: "Message #{uid} deleted from #{folder}" }])
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module MailMCP
|
|
2
|
+
class GetMailMessageTool < Tool
|
|
3
|
+
tool_name "get_mail_message"
|
|
4
|
+
description "Fetch a full email message including body and attachment URLs"
|
|
5
|
+
annotations(
|
|
6
|
+
title: "Get Mail Message",
|
|
7
|
+
read_only_hint: true,
|
|
8
|
+
destructive_hint: false,
|
|
9
|
+
idempotent_hint: false,
|
|
10
|
+
open_world_hint: true
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
input_schema(
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
folder: { type: "string", description: "Mailbox folder name" },
|
|
17
|
+
uid: { type: "integer", description: "Message UID" }
|
|
18
|
+
},
|
|
19
|
+
required: %w[folder uid]
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def self.call(folder:, uid:, server_context:)
|
|
23
|
+
msg = ImapClient.connect(server_context.imap_config) { |c| c.get_message(folder: folder, uid: uid) }
|
|
24
|
+
return MCP::Tool::Response.new([{ type: "text", text: "Message not found" }], error: true) unless msg
|
|
25
|
+
|
|
26
|
+
MCP::Tool::Response.new([{ type: "text", text: JSON.generate(msg) }])
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module MailMCP
|
|
2
|
+
class ListMailMessagesTool < Tool
|
|
3
|
+
tool_name "list_mail_messages"
|
|
4
|
+
description "List messages in an IMAP folder with pagination"
|
|
5
|
+
annotations(
|
|
6
|
+
title: "List Mail Messages",
|
|
7
|
+
read_only_hint: true,
|
|
8
|
+
destructive_hint: false,
|
|
9
|
+
idempotent_hint: false,
|
|
10
|
+
open_world_hint: true
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
input_schema(
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
folder: { type: "string", description: "Mailbox folder name" },
|
|
17
|
+
page: { type: "integer", description: "Page number (default 1)", default: 1 },
|
|
18
|
+
per_page: { type: "integer", description: "Messages per page (default 20)", default: 20 }
|
|
19
|
+
},
|
|
20
|
+
required: ["folder"]
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def self.call(folder:, server_context:, page: 1, per_page: 20)
|
|
24
|
+
result = ImapClient.connect(server_context.imap_config) do |c|
|
|
25
|
+
c.list_messages(folder: folder, page: page, per_page: per_page)
|
|
26
|
+
end
|
|
27
|
+
MCP::Tool::Response.new([{ type: "text", text: JSON.generate(result) }])
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module MailMCP
|
|
2
|
+
class ListMailboxesTool < Tool
|
|
3
|
+
tool_name "list_mailboxes"
|
|
4
|
+
description "List all IMAP mailboxes/folders"
|
|
5
|
+
annotations(
|
|
6
|
+
title: "List Mailboxes",
|
|
7
|
+
read_only_hint: true,
|
|
8
|
+
destructive_hint: false,
|
|
9
|
+
idempotent_hint: false,
|
|
10
|
+
open_world_hint: true
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
def self.call(server_context:)
|
|
14
|
+
mailboxes = ImapClient.connect(server_context.imap_config, &:list_mailboxes)
|
|
15
|
+
MCP::Tool::Response.new([{ type: "text", text: JSON.generate(mailboxes) }])
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module MailMCP
|
|
2
|
+
class MoveMailMessageTool < Tool
|
|
3
|
+
tool_name "move_mail_message"
|
|
4
|
+
description "Move a message to another folder"
|
|
5
|
+
annotations(
|
|
6
|
+
title: "Move Mail Message",
|
|
7
|
+
read_only_hint: false,
|
|
8
|
+
destructive_hint: true,
|
|
9
|
+
idempotent_hint: false,
|
|
10
|
+
open_world_hint: true
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
input_schema(
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
folder: { type: "string", description: "Source folder" },
|
|
17
|
+
uid: { type: "integer" },
|
|
18
|
+
destination: { type: "string", description: "Destination folder" }
|
|
19
|
+
},
|
|
20
|
+
required: %w[folder uid destination]
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def self.call(folder:, uid:, destination:, server_context:)
|
|
24
|
+
ImapClient.connect(server_context.imap_config) do |c|
|
|
25
|
+
c.move_message(folder: folder, uid: uid, destination: destination)
|
|
26
|
+
end
|
|
27
|
+
MCP::Tool::Response.new([{ type: "text", text: "Message #{uid} moved from #{folder} to #{destination}" }])
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module MailMCP
|
|
2
|
+
class SearchMailMessagesTool < Tool
|
|
3
|
+
tool_name "search_mail_messages"
|
|
4
|
+
description "Search messages in an IMAP folder using raw IMAP SEARCH criteria"
|
|
5
|
+
annotations(
|
|
6
|
+
title: "Search Mail Messages",
|
|
7
|
+
read_only_hint: true,
|
|
8
|
+
destructive_hint: false,
|
|
9
|
+
idempotent_hint: false,
|
|
10
|
+
open_world_hint: true
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
input_schema(
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
folder: { type: "string", description: "Mailbox folder name, e.g. INBOX" },
|
|
17
|
+
query: { type: "string",
|
|
18
|
+
description: "Raw IMAP SEARCH criteria, e.g. 'UNSEEN' or " \
|
|
19
|
+
"'FROM alice@example.com SINCE 01-Jan-2025'" }
|
|
20
|
+
},
|
|
21
|
+
required: %w[folder query]
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def self.call(folder:, query:, server_context:)
|
|
25
|
+
uids = ImapClient.connect(server_context.imap_config) { |c| c.search_messages(folder: folder, query: query) }
|
|
26
|
+
MCP::Tool::Response.new([{ type: "text",
|
|
27
|
+
text: JSON.generate({ folder: folder, query: query, uids: uids,
|
|
28
|
+
count: uids.length }) }])
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module MailMCP
|
|
2
|
+
class SendMailMessageTool < Tool
|
|
3
|
+
tool_name "send_mail_message"
|
|
4
|
+
description "Send an email via SMTP"
|
|
5
|
+
annotations(
|
|
6
|
+
title: "Send Mail Message",
|
|
7
|
+
read_only_hint: false,
|
|
8
|
+
destructive_hint: true,
|
|
9
|
+
idempotent_hint: false,
|
|
10
|
+
open_world_hint: true
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
input_schema(
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
to: { type: "string", description: "Recipient address(es), comma-separated" },
|
|
17
|
+
subject: { type: "string" },
|
|
18
|
+
text_body: { type: "string", description: "Plain-text body" },
|
|
19
|
+
cc: { type: "string" },
|
|
20
|
+
bcc: { type: "string" },
|
|
21
|
+
html_body: { type: "string", description: "HTML body (optional)" },
|
|
22
|
+
attachment_urls: { type: "array", items: { type: "string" },
|
|
23
|
+
description: "S3 presigned URLs to attach" },
|
|
24
|
+
folder: { type: "string", description: "IMAP folder to save the sent message (default: Sent)",
|
|
25
|
+
default: "Sent" }
|
|
26
|
+
},
|
|
27
|
+
required: %w[to subject text_body]
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def self.call(to:, subject:, text_body:, server_context:, cc: nil, bcc: nil, html_body: nil,
|
|
31
|
+
attachment_urls: [], folder: "Sent")
|
|
32
|
+
mail = MailBuilder.build(
|
|
33
|
+
from: server_context.imap_config[:username],
|
|
34
|
+
to: to, subject: subject, text_body: text_body,
|
|
35
|
+
cc: cc, bcc: bcc, html_body: html_body,
|
|
36
|
+
attachment_urls: attachment_urls
|
|
37
|
+
)
|
|
38
|
+
SmtpClient.send(server_context.smtp_config, mail)
|
|
39
|
+
ImapClient.connect(server_context.imap_config) { |c| c.append_message(folder: folder, raw_message: mail.to_s) }
|
|
40
|
+
MCP::Tool::Response.new([{ type: "text", text: "Email sent successfully to #{to} and saved to #{folder}" }])
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module MailMCP
|
|
2
|
+
class UpdateMailMessageFlagsTool < Tool
|
|
3
|
+
tool_name "update_mail_message_flags"
|
|
4
|
+
description "Update IMAP flags on a message (mark as read, flagged, etc.)"
|
|
5
|
+
annotations(
|
|
6
|
+
title: "Update Mail Message Flags",
|
|
7
|
+
read_only_hint: false,
|
|
8
|
+
destructive_hint: true,
|
|
9
|
+
idempotent_hint: false,
|
|
10
|
+
open_world_hint: true
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
input_schema(
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
folder: { type: "string" },
|
|
17
|
+
uid: { type: "integer" },
|
|
18
|
+
add: { type: "array", items: { type: "string" },
|
|
19
|
+
description: "Flags to add, e.g. ['\\\\Seen', '\\\\Flagged']" },
|
|
20
|
+
remove: { type: "array", items: { type: "string" }, description: "Flags to remove" }
|
|
21
|
+
},
|
|
22
|
+
required: %w[folder uid]
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def self.call(folder:, uid:, server_context:, add: [], remove: [])
|
|
26
|
+
add_flags = add.map(&:to_sym)
|
|
27
|
+
remove_flags = remove.map(&:to_sym)
|
|
28
|
+
ImapClient.connect(server_context.imap_config) do |c|
|
|
29
|
+
c.update_flags(folder: folder, uid: uid, add: add_flags, remove: remove_flags)
|
|
30
|
+
end
|
|
31
|
+
MCP::Tool::Response.new([{ type: "text", text: "Flags updated for message #{uid}" }])
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|