mail_mcp 0.1.0 → 1.0.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52c052a557e253241811bc0b5342a6980846ab1475761ea9b337410ed5488ed3
4
- data.tar.gz: 4e8ab0e13a25ba3ab8eea26228a55d284bcd2dc4ca403a42e695585e56253643
3
+ metadata.gz: c33a2c64482699085c980befb438655c2d599512422281bd3c3cb48a32582500
4
+ data.tar.gz: 3dfbfeba54ef8f83d3bf99c7a727f7207c7245032c5df8eb6fbb53af4c089fb7
5
5
  SHA512:
6
- metadata.gz: 5f47ac7a7114328a2cd8b10febe15fcd1d742f0d3ebdbbcb48556ce9a71ff7ca65af36ce5edeafc719bc8a279dbc4366132d1e56fe514347d7d16dbd952d30c0
7
- data.tar.gz: 03f3ae4b06d27920d667f767757eaa6f2bcaf7bc0955f8774f4f6fe457b29247760ea65203e078979025c3420b4ce5ca27f055a73e629e1807582e1791aba555
6
+ metadata.gz: ba9e0f6e1dd9b84146a637d748e2559d60080f5358fd83718d7e162a513d964d8f4e492335c96435bac2954176981553700eb89227c2e8a54d12e48c230dbc76
7
+ data.tar.gz: 5063a62efb64854f0dfe47d2880621e9ee32a239280f78fd4d8202b405dbed45e8cc697a4269ceb59ba482e126c2cfcc6d2edbfbf29fd32abbf016ee4596d1fc
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Edgars Beigarts
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Mitigate Mail MCP
1
+ # Mail MCP
2
2
 
3
3
  A hosted [Model Context Protocol](https://modelcontextprotocol.io/) server for IMAP and SMTP email, built in Ruby. It acts as both an OAuth 2.1 Authorization Server and an MCP Resource Server.
4
4
 
@@ -8,7 +8,7 @@ A hosted [Model Context Protocol](https://modelcontextprotocol.io/) server for I
8
8
  Client (Claude Desktop / MCP Inspector)
9
9
 
10
10
  ├─ OAuth 2.1 flow
11
- │ GET /.well-known/oauth-protected-resource RFC 9728 metadata
11
+ │ GET /.well-known/oauth-protected-resource RFC 9728 metadata
12
12
  │ GET /.well-known/oauth-authorization-server RFC 8414 metadata
13
13
  │ GET /oauth/authorize Login UI
14
14
  │ POST /oauth/authorize Validate IMAP/SMTP → issue code
@@ -36,15 +36,17 @@ The OAuth flow:
36
36
  6. Client exchanges the authorization code (`POST /oauth/token`) with `client_id` + `client_secret`; receives `access_token` + `refresh_token`
37
37
  7. Every MCP request carries `Authorization: Bearer <access_token>`; the server decrypts credentials per-request
38
38
 
39
+ ![Login form](assets/form.png)
40
+
39
41
  ### Token formats
40
42
 
41
43
  All tokens are **5-part JWE** (`dir` / `A256GCM`), encrypted with `ENCRYPTION_KEY`. There is no separate signing key.
42
44
 
43
- | Token | `typ` claim | Expiry | Contents |
44
- |---|---|---|---|
45
- | `client_id` | `client_id` | none | imap/smtp host+port, `client_secret` |
46
- | Access token | `access` | 8 hours | IMAP + SMTP credentials |
47
- | Refresh token | `refresh` | 30 days | IMAP + SMTP credentials |
45
+ | Token | `typ` claim | Expiry | Contents |
46
+ |---------------|-------------|---------|--------------------------------------|
47
+ | `client_id` | `client_id` | none | imap/smtp host+port, `client_secret` |
48
+ | Access token | `access` | 8 hours | IMAP + SMTP credentials |
49
+ | Refresh token | `refresh` | 30 days | IMAP + SMTP credentials |
48
50
 
49
51
  ## Directory Structure
50
52
 
@@ -79,16 +81,17 @@ mail_mcp/
79
81
 
80
82
  Copy `.env.sample` to `.env` and fill in the values:
81
83
 
82
- | Variable | Description |
83
- |---|---|
84
- | `BASE_URL` | Public URL of this server, e.g. `https://mail.mcp.mitigate.dev` |
85
- | `ENCRYPTION_KEY` | AES-256 key (base64-encoded 32 bytes) — used for **all** JWE tokens |
86
- | `AWS_ACCESS_KEY_ID` | AWS credentials for S3 attachment storage |
87
- | `AWS_SECRET_ACCESS_KEY` | AWS credentials for S3 attachment storage |
88
- | `AWS_REGION` | S3 bucket region, e.g. `us-east-1` |
89
- | `AWS_S3_BUCKET` | S3 bucket name for attachments |
90
- | `PORT` | HTTP port (default `3000`) |
91
- | `RACK_ENV` | `development` or `production` |
84
+ | Variable | Description |
85
+ |-------------------------|---------------------------------------------------------------------|
86
+ | `BASE_URL` | Public URL of this server, e.g. `https://mail.mcp.example.com` |
87
+ | `ENCRYPTION_KEY` | AES-256 key (base64-encoded 32 bytes) — used for **all** JWE tokens |
88
+ | `AWS_ACCESS_KEY_ID` | AWS credentials for S3 attachment storage |
89
+ | `AWS_SECRET_ACCESS_KEY` | AWS credentials for S3 attachment storage |
90
+ | `AWS_REGION` | S3 bucket region, e.g. `us-east-1` |
91
+ | `AWS_S3_BUCKET` | S3 bucket name for attachments |
92
+ | `PORT` | HTTP port (default `3000`) |
93
+ | `RACK_ENV` | `development` or `production` |
94
+ | `MAIL_MCP_LOG_LEVEL` | Log level (`DEBUG`, `INFO`, `WARN`, `ERROR`, default `INFO`) |
92
95
 
93
96
  Generate `ENCRYPTION_KEY`:
94
97
  ```bash
@@ -123,23 +126,23 @@ bundle exec bin/mail_mcp generate \
123
126
  --imap-host=imap.gmail.com \
124
127
  --imap-port=993 \
125
128
  --smtp-host=smtp.gmail.com \
126
- --smtp-port=587
129
+ --smtp-port=465
127
130
 
128
131
  # Client ID: eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0...<encrypted>
129
132
  # Client Secret: 713576e2f94802b9d9abfd755e38e29b63e491df...
130
133
  #
131
134
  # IMAP: imap.gmail.com:993 (ssl=true)
132
- # SMTP: smtp.gmail.com:587 (ssl=false)
135
+ # SMTP: smtp.gmail.com:465 (ssl=true)
133
136
  ```
134
137
 
135
- | Flag | Default | Description |
136
- |---|---|---|
137
- | `--imap-host=HOST` | required | IMAP server hostname |
138
- | `--imap-port=PORT` | `993` | IMAP port |
139
- | `--[no-]imap-ssl` | `true` when port 993 | Enable SSL/TLS for IMAP |
140
- | `--smtp-host=HOST` | required | SMTP server hostname |
141
- | `--smtp-port=PORT` | `587` | SMTP port |
142
- | `--[no-]smtp-ssl` | `true` when port 465 | Enable SSL/TLS for SMTP |
138
+ | Flag | Default | Description |
139
+ |--------------------|----------------------|-------------------------|
140
+ | `--imap-host=HOST` | required | IMAP server hostname |
141
+ | `--imap-port=PORT` | `993` | IMAP port |
142
+ | `--[no-]imap-ssl` | `true` when port 993 | Enable SSL/TLS for IMAP |
143
+ | `--smtp-host=HOST` | required | SMTP server hostname |
144
+ | `--smtp-port=PORT` | `465` | SMTP port |
145
+ | `--[no-]smtp-ssl` | `true` when port 465 | Enable SSL/TLS for SMTP |
143
146
 
144
147
  ## OAuth 2.1 Flow
145
148
 
@@ -152,17 +155,17 @@ bundle exec bin/mail_mcp generate \
152
155
 
153
156
  ## MCP Tools
154
157
 
155
- | Tool | Parameters | Description |
156
- |---|---|---|
157
- | `list_mailboxes` | — | List all IMAP folders |
158
- | `list_mail_messages` | `folder`, `page`, `per_page` | List messages with pagination |
159
- | `get_mail_message` | `folder`, `uid` | Fetch full message; attachments uploaded to S3 and returned as presigned URLs |
160
- | `search_mail_messages` | `folder`, `query` | Raw IMAP SEARCH criteria, e.g. `UNSEEN` or `FROM alice@example.com SINCE 01-Jan-2025` |
161
- | `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 |
162
- | `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 |
163
- | `delete_mail_message` | `folder`, `uid` | Mark `\Deleted` + EXPUNGE |
164
- | `move_mail_message` | `folder`, `uid`, `destination` | IMAP MOVE (or COPY+DELETE fallback) |
165
- | `update_mail_message_flags` | `folder`, `uid`, `add`, `remove` | Add/remove IMAP flags, e.g. `\Seen`, `\Flagged` |
158
+ | Tool | Parameters | Description |
159
+ |-----------------------------|-------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------|
160
+ | `list_mailboxes` | — | List all IMAP folders |
161
+ | `list_mail_messages` | `folder`, `page`, `per_page` | List messages with pagination |
162
+ | `get_mail_message` | `folder`, `uid` | Fetch full message; attachments uploaded to S3 and returned as presigned URLs |
163
+ | `search_mail_messages` | `folder`, `query` | Raw IMAP SEARCH criteria, e.g. `UNSEEN` or `FROM alice@example.com SINCE 01-Jan-2025` |
164
+ | `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 |
165
+ | `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 |
166
+ | `delete_mail_message` | `folder`, `uid` | Mark `\Deleted` + EXPUNGE |
167
+ | `move_mail_message` | `folder`, `uid`, `destination` | IMAP MOVE (or COPY+DELETE fallback) |
168
+ | `update_mail_message_flags` | `folder`, `uid`, `add`, `remove` | Add/remove IMAP flags, e.g. `\Seen`, `\Flagged` |
166
169
 
167
170
  Attachments are never returned as binary data — they are uploaded to S3 on first access and returned as presigned URLs valid for 7 days.
168
171
 
data/bin/mail_mcp CHANGED
@@ -31,7 +31,7 @@ def generate_option_parser(options)
31
31
  opts.on("--imap-host=HOST", "IMAP server hostname, e.g. imap.gmail.com")
32
32
  opts.on("--imap-port=PORT", Integer, "IMAP port (default: 993)")
33
33
  opts.on("--smtp-host=HOST", "SMTP server hostname, e.g. smtp.gmail.com")
34
- opts.on("--smtp-port=PORT", Integer, "SMTP port (default: 587)")
34
+ opts.on("--smtp-port=PORT", Integer, "SMTP port (default: 465)")
35
35
  opts.separator ""
36
36
  opts.separator "Optional:"
37
37
  opts.on("--[no-]imap-ssl", "Enable SSL/TLS for IMAP (default: true when port 993)")
@@ -56,7 +56,7 @@ def normalize_generate_options(options, parser)
56
56
  end
57
57
 
58
58
  imap_port = options[:"imap-port"] || 993
59
- smtp_port = options[:"smtp-port"] || 587
59
+ smtp_port = options[:"smtp-port"] || 465
60
60
  {
61
61
  imap_host: imap_host,
62
62
  imap_port: imap_port,
data/lib/mail_mcp/app.rb CHANGED
@@ -70,86 +70,29 @@ module MailMCP
70
70
  client_config = decode_client_id!(params[:client_id])
71
71
  return if client_config.nil?
72
72
 
73
- @params = {
74
- client_id: params[:client_id],
75
- redirect_uri: params[:redirect_uri],
76
- state: params[:state],
77
- code_challenge: params[:code_challenge],
78
- code_challenge_method: params[:code_challenge_method],
79
- imap_host: client_config["imap_host"],
80
- smtp_host: client_config["smtp_host"],
81
- error: nil
82
- }
73
+ params[:use_same_credentials] = "1"
74
+ @form = LoginForm.new(params, client_config)
83
75
  erb :login
84
76
  end
85
77
 
86
78
  post "/oauth/authorize" do
87
- client_id = params[:client_id]
88
- redirect_uri = params[:redirect_uri]
89
- state = params[:state]
90
- code_challenge = params[:code_challenge]
91
- code_challenge_method = params[:code_challenge_method]
92
-
93
- client_config = decode_client_id!(client_id)
79
+ client_config = decode_client_id!(params[:client_id])
94
80
  return if client_config.nil?
95
81
 
96
- imap_host = client_config["imap_host"]
97
- imap_port = client_config["imap_port"]
98
- imap_ssl = client_config["imap_ssl"]
99
- imap_user = params[:imap_username].to_s.strip
100
- imap_pass = params[:imap_password].to_s
101
-
102
- use_same = params[:use_same_credentials] == "1"
103
- smtp_host = client_config["smtp_host"]
104
- smtp_port = client_config["smtp_port"]
105
- smtp_ssl = client_config["smtp_ssl"]
106
- smtp_user = use_same ? imap_user : params[:smtp_username].to_s.strip
107
- smtp_pass = use_same ? imap_pass : params[:smtp_password].to_s
108
-
109
- errors = []
110
-
111
- imap_cfg = { host: imap_host, port: imap_port, ssl: imap_ssl, username: imap_user, password: imap_pass }
112
- begin
113
- ImapClient.validate!(imap_cfg)
114
- rescue ImapClient::AuthError, ImapClient::ConnectionError => e
115
- errors << "IMAP: #{e.message}"
116
- end
117
-
118
- smtp_cfg = { host: smtp_host, port: smtp_port, ssl: smtp_ssl, username: smtp_user, password: smtp_pass }
119
- begin
120
- SmtpClient.validate!(smtp_cfg)
121
- rescue SmtpClient::ConnectionError => e
122
- errors << "SMTP: #{e.message}"
123
- end
124
-
125
- unless errors.empty?
126
- @params = {
127
- client_id: client_id, redirect_uri: redirect_uri, state: state,
128
- code_challenge: code_challenge, code_challenge_method: code_challenge_method,
129
- imap_host: imap_host, smtp_host: smtp_host,
130
- error: errors.join(". ")
131
- }
132
- return erb :login
133
- end
134
-
135
- creds = {
136
- "imap_host" => imap_host, "imap_port" => imap_port, "imap_ssl" => imap_ssl,
137
- "imap_username" => imap_user, "imap_password" => imap_pass,
138
- "smtp_host" => smtp_host, "smtp_port" => smtp_port, "smtp_ssl" => smtp_ssl,
139
- "smtp_username" => smtp_user, "smtp_password" => smtp_pass
140
- }
82
+ @form = LoginForm.new(params, client_config)
83
+ return erb :login unless @form.valid?
141
84
 
142
85
  code = JwtService.issue_code(
143
- creds: creds,
144
- code_challenge: code_challenge,
145
- redirect_uri: redirect_uri,
146
- client_id: client_id
86
+ creds: @form.creds,
87
+ code_challenge: params[:code_challenge],
88
+ redirect_uri: params[:redirect_uri],
89
+ client_id: params[:client_id]
147
90
  )
148
91
 
149
- redirect_to = URI.parse(redirect_uri)
92
+ redirect_to = URI.parse(params[:redirect_uri])
150
93
  query = URI.decode_www_form(redirect_to.query.to_s)
151
94
  query << ["code", code]
152
- query << ["state", state] if state
95
+ query << ["state", params[:state]] if params[:state]
153
96
  redirect_to.query = URI.encode_www_form(query)
154
97
  redirect redirect_to.to_s
155
98
  end
@@ -212,7 +155,9 @@ module MailMCP
212
155
  smtp_config: {
213
156
  host: creds["smtp_host"], port: creds["smtp_port"].to_i,
214
157
  ssl: creds["smtp_ssl"], username: creds["smtp_username"], password: creds["smtp_password"]
215
- }
158
+ },
159
+ email: creds["email"],
160
+ full_name: creds["full_name"]
216
161
  )
217
162
  [context, nil]
218
163
  rescue JwtService::Error => e
@@ -1,5 +1,5 @@
1
1
  module MailMCP
2
2
  # Immutable credential value object passed as server_context to each per-request MCP server.
3
3
  # MCP::ServerContext delegates imap_config / smtp_config to this via method_missing.
4
- CredentialContext = Struct.new(:imap_config, :smtp_config, keyword_init: true)
4
+ CredentialContext = Struct.new(:imap_config, :smtp_config, :email, :full_name, keyword_init: true)
5
5
  end
@@ -5,6 +5,9 @@ module MailMCP
5
5
  class AuthError < StandardError; end
6
6
  class ConnectionError < StandardError; end
7
7
 
8
+ OPEN_TIMEOUT = 10
9
+ IDLE_TIMEOUT = 30
10
+
8
11
  attr_reader :imap
9
12
 
10
13
  def initialize(imap)
@@ -12,24 +15,32 @@ module MailMCP
12
15
  end
13
16
 
14
17
  def self.validate!(config)
15
- conn = Net::IMAP.new(config[:host], port: config[:port], ssl: config[:ssl])
18
+ conn = open_connection(config)
16
19
  conn.login(config[:username], config[:password])
17
20
  conn.logout
18
21
  conn.disconnect
19
22
  rescue Net::IMAP::NoResponseError, Net::IMAP::BadResponseError => e
23
+ MailMCP.logger.warn { "IMAP authentication failed user=#{config[:username]}: #{e.message}" }
20
24
  raise AuthError, "IMAP authentication failed: #{e.message}"
21
25
  rescue StandardError => e
26
+ MailMCP.logger.error do
27
+ "IMAP connection failed host=#{config[:host]}:#{config[:port]} ssl=#{config[:ssl]}: #{e.class}: #{e.message}"
28
+ end
22
29
  raise ConnectionError, "IMAP connection failed: #{e.message}"
23
30
  end
24
31
 
25
32
  def self.connect(config)
26
- conn = Net::IMAP.new(config[:host], port: config[:port], ssl: config[:ssl])
33
+ conn = open_connection(config)
27
34
  conn.login(config[:username], config[:password])
28
35
  client = new(conn)
29
36
  yield client
30
37
  rescue Net::IMAP::NoResponseError, Net::IMAP::BadResponseError => e
38
+ MailMCP.logger.warn { "IMAP authentication failed user=#{config[:username]}: #{e.message}" }
31
39
  raise AuthError, "IMAP authentication failed: #{e.message}"
32
40
  rescue StandardError => e
41
+ MailMCP.logger.error do
42
+ "IMAP connection failed host=#{config[:host]}:#{config[:port]} ssl=#{config[:ssl]}: #{e.class}: #{e.message}"
43
+ end
33
44
  raise ConnectionError, "IMAP connection failed: #{e.message}"
34
45
  ensure
35
46
  begin
@@ -45,15 +56,18 @@ module MailMCP
45
56
  end
46
57
 
47
58
  def list_mailboxes
59
+ MailMCP.logger.info { "IMAP list_mailboxes" }
48
60
  @imap.list("", "*").map(&:name)
49
61
  end
50
62
 
51
63
  def list_messages(folder:, page: 1, per_page: 20)
64
+ MailMCP.logger.info { "IMAP list_messages folder=#{folder.inspect} page=#{page} per_page=#{per_page}" }
52
65
  @imap.examine(folder)
53
66
  uids = @imap.uid_search(["ALL"]).reverse
54
67
  total = uids.length
55
68
  offset = (page - 1) * per_page
56
69
  page_uids = uids[offset, per_page] || []
70
+ MailMCP.logger.debug { "IMAP list_messages folder=#{folder.inspect} total=#{total} returned=#{page_uids.size}" }
57
71
  return { messages: [], total: total } if page_uids.empty?
58
72
 
59
73
  envelopes = @imap.uid_fetch(page_uids, ["ENVELOPE", "FLAGS", "RFC822.SIZE"])
@@ -62,44 +76,39 @@ module MailMCP
62
76
  end
63
77
 
64
78
  def get_message(folder:, uid:)
79
+ MailMCP.logger.info { "IMAP get_message folder=#{folder.inspect} uid=#{uid}" }
65
80
  @imap.examine(folder)
66
81
  data = @imap.uid_fetch([uid.to_i], %w[RFC822 FLAGS]).first
67
- return nil unless data
82
+ unless data
83
+ MailMCP.logger.warn { "IMAP get_message not found folder=#{folder.inspect} uid=#{uid}" }
84
+ return nil
85
+ end
68
86
 
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
- }
87
+ format_message(uid: uid, parsed: Mail.new(data.attr["RFC822"]), flags: data.attr["FLAGS"])
85
88
  end
86
89
 
87
90
  def search_messages(folder:, query:)
91
+ MailMCP.logger.info { "IMAP search_messages folder=#{folder.inspect} query=#{query.inspect}" }
88
92
  @imap.examine(folder)
89
- @imap.search(query.split)
93
+ results = @imap.search(query.split)
94
+ MailMCP.logger.debug { "IMAP search_messages matched=#{results.size}" }
95
+ results
90
96
  end
91
97
 
92
98
  def delete_message(folder:, uid:)
99
+ MailMCP.logger.info { "IMAP delete_message folder=#{folder.inspect} uid=#{uid}" }
93
100
  @imap.select(folder)
94
101
  @imap.uid_store(uid.to_i, "+FLAGS", [:Deleted])
95
102
  @imap.expunge
96
103
  end
97
104
 
98
105
  def move_message(folder:, uid:, destination:)
106
+ MailMCP.logger.info { "IMAP move_message folder=#{folder.inspect} uid=#{uid} destination=#{destination.inspect}" }
99
107
  @imap.select(folder)
100
108
  if @imap.capability.include?("MOVE")
101
109
  @imap.uid_move(uid.to_i, destination)
102
110
  else
111
+ MailMCP.logger.debug { "IMAP move_message falling back to copy+delete (server lacks MOVE)" }
103
112
  @imap.uid_copy(uid.to_i, destination)
104
113
  @imap.uid_store(uid.to_i, "+FLAGS", [:Deleted])
105
114
  @imap.expunge
@@ -107,24 +116,66 @@ module MailMCP
107
116
  end
108
117
 
109
118
  def update_flags(folder:, uid:, add: [], remove: [])
119
+ MailMCP.logger.info do
120
+ "IMAP update_flags folder=#{folder.inspect} uid=#{uid} add=#{add.inspect} remove=#{remove.inspect}"
121
+ end
110
122
  @imap.select(folder)
111
123
  @imap.uid_store(uid.to_i, "+FLAGS", add) unless add.empty?
112
124
  @imap.uid_store(uid.to_i, "-FLAGS", remove) unless remove.empty?
113
125
  end
114
126
 
115
- def append_message(folder:, raw_message:)
116
- @imap.append(folder, raw_message, [:Draft], Time.now)
127
+ def append_message(folder:, raw_message:, flags: [:Seen])
128
+ MailMCP.logger.info do
129
+ "IMAP append_message folder=#{folder.inspect} flags=#{flags.inspect} bytes=#{raw_message.bytesize}"
130
+ end
131
+ @imap.append(folder, raw_message, flags, Time.now)
117
132
  end
118
133
 
134
+ def self.open_connection(config)
135
+ MailMCP.logger.debug do
136
+ "IMAP connect host=#{config[:host]} port=#{config[:port]} ssl=#{config[:ssl]} user=#{config[:username]}"
137
+ end
138
+ Net::IMAP.new(config[:host], port: config[:port], ssl: config[:ssl], open_timeout: OPEN_TIMEOUT,
139
+ idle_response_timeout: IDLE_TIMEOUT)
140
+ end
141
+ private_class_method :open_connection
142
+
119
143
  private
120
144
 
145
+ def format_message(uid:, parsed:, flags:)
146
+ {
147
+ uid: uid,
148
+ message_id: parsed.message_id,
149
+ in_reply_to: parsed.in_reply_to,
150
+ references: parsed.references,
151
+ subject: parsed.subject,
152
+ from: parsed.from,
153
+ sender: parsed.sender,
154
+ reply_to: parsed.reply_to,
155
+ to: parsed.to,
156
+ cc: parsed.cc,
157
+ bcc: parsed.bcc,
158
+ date: parsed.date&.iso8601,
159
+ text_body: parsed.text_part&.decoded,
160
+ html_body: parsed.html_part&.decoded,
161
+ flags: flags,
162
+ attachments: extract_attachments(parsed)
163
+ }
164
+ end
165
+
121
166
  def format_envelope(msg)
122
167
  env = msg.attr["ENVELOPE"]
123
168
  {
124
169
  uid: msg.attr["UID"],
170
+ message_id: env.message_id,
171
+ in_reply_to: env.in_reply_to,
125
172
  subject: env.subject,
126
173
  from: format_addresses(env.from),
174
+ sender: format_addresses(env.sender),
175
+ reply_to: format_addresses(env.reply_to),
127
176
  to: format_addresses(env.to),
177
+ cc: format_addresses(env.cc),
178
+ bcc: format_addresses(env.bcc),
128
179
  date: env.date,
129
180
  size: msg.attr["RFC822.SIZE"],
130
181
  flags: msg.attr["FLAGS"]
@@ -10,6 +10,7 @@ module MailMCP
10
10
  CRED_KEYS = %w[
11
11
  imap_host imap_port imap_ssl imap_username imap_password
12
12
  smtp_host smtp_port smtp_ssl smtp_username smtp_password
13
+ email full_name
13
14
  ].freeze
14
15
 
15
16
  # Access token — JWE (dir/A256GCM), credentials embedded directly in payload
@@ -0,0 +1,72 @@
1
+ module MailMCP
2
+ class LoginForm
3
+ attr_reader :errors
4
+
5
+ def initialize(params, client_config)
6
+ @params = params
7
+ @client_config = client_config
8
+ @errors = []
9
+ end
10
+
11
+ def valid?
12
+ @errors = []
13
+ @errors << "Email: address is required" if email.empty?
14
+ validate_imap
15
+ validate_smtp
16
+ @errors.empty?
17
+ end
18
+
19
+ def client_id = @params[:client_id]
20
+ def redirect_uri = @params[:redirect_uri]
21
+ def state = @params[:state]
22
+ def code_challenge = @params[:code_challenge]
23
+ def code_challenge_method = @params[:code_challenge_method]
24
+
25
+ def imap_host = @client_config["imap_host"]
26
+ def smtp_host = @client_config["smtp_host"]
27
+
28
+ def imap_username = @params[:imap_username].to_s.strip
29
+ def imap_password = @params[:imap_password].to_s
30
+ def smtp_username = use_same_credentials? ? imap_username : @params[:smtp_username].to_s.strip
31
+ def smtp_password = use_same_credentials? ? imap_password : @params[:smtp_password].to_s
32
+ def email = @params[:email].to_s.strip
33
+ def full_name = @params[:full_name].to_s.strip
34
+ def use_same_credentials? = @params[:use_same_credentials] == "1"
35
+
36
+ def imap_config
37
+ { host: imap_host, port: @client_config["imap_port"], ssl: @client_config["imap_ssl"],
38
+ username: imap_username, password: imap_password }
39
+ end
40
+
41
+ def smtp_config
42
+ { host: smtp_host, port: @client_config["smtp_port"], ssl: @client_config["smtp_ssl"],
43
+ username: smtp_username, password: smtp_password }
44
+ end
45
+
46
+ def creds
47
+ {
48
+ "imap_host" => imap_host, "imap_port" => @client_config["imap_port"],
49
+ "imap_ssl" => @client_config["imap_ssl"],
50
+ "imap_username" => imap_username, "imap_password" => imap_password,
51
+ "smtp_host" => smtp_host, "smtp_port" => @client_config["smtp_port"],
52
+ "smtp_ssl" => @client_config["smtp_ssl"],
53
+ "smtp_username" => smtp_username, "smtp_password" => smtp_password,
54
+ "email" => email, "full_name" => full_name
55
+ }
56
+ end
57
+
58
+ private
59
+
60
+ def validate_imap
61
+ ImapClient.validate!(imap_config)
62
+ rescue ImapClient::AuthError, ImapClient::ConnectionError => e
63
+ @errors << "IMAP: #{e.message}"
64
+ end
65
+
66
+ def validate_smtp
67
+ SmtpClient.validate!(smtp_config)
68
+ rescue SmtpClient::ConnectionError => e
69
+ @errors << "SMTP: #{e.message}"
70
+ end
71
+ end
72
+ end
@@ -10,17 +10,46 @@ module MailMCP
10
10
 
11
11
  def self.send(config, mail)
12
12
  smtp_open(config) do |s|
13
- s.send_message(mail.to_s, mail.from.first, mail.to)
13
+ recipients = mail.destinations
14
+ MailMCP.logger.info do
15
+ "SMTP send to=#{recipients.join(",")} from=#{mail.from&.first} subject=#{mail.subject.inspect}"
16
+ end
17
+ s.send_message(encoded_for_wire(mail), mail.from.first, recipients)
14
18
  end
15
19
  rescue Net::SMTPError, SocketError => e
20
+ MailMCP.logger.error { "SMTP send failed: #{e.class}: #{e.message}" }
16
21
  raise ConnectionError, "SMTP send failed: #{e.message}"
17
22
  end
18
23
 
24
+ def self.encoded_for_wire(mail)
25
+ return mail.encoded if mail.bcc.nil? || mail.bcc.empty?
26
+
27
+ wire = Mail.new(mail.encoded)
28
+ wire.bcc = nil
29
+ wire.encoded
30
+ end
31
+ private_class_method :encoded_for_wire
32
+
33
+ OPEN_TIMEOUT = 10
34
+ READ_TIMEOUT = 30
35
+
19
36
  def self.smtp_open(config, &)
37
+ MailMCP.logger.debug do
38
+ "SMTP connect host=#{config[:host]} port=#{config[:port]} ssl=#{config[:ssl]} user=#{config[:username]}"
39
+ end
20
40
  smtp = Net::SMTP.new(config[:host], config[:port])
21
- smtp.enable_starttls_auto unless config[:ssl]
41
+ smtp.open_timeout = OPEN_TIMEOUT
42
+ smtp.read_timeout = READ_TIMEOUT
43
+ if config[:ssl]
44
+ smtp.enable_tls
45
+ else
46
+ smtp.enable_starttls_auto
47
+ end
22
48
  smtp.start(config[:host], config[:username], config[:password], :login, &)
23
49
  rescue StandardError => e
50
+ MailMCP.logger.error do
51
+ "SMTP connection failed host=#{config[:host]}:#{config[:port]} ssl=#{config[:ssl]}: #{e.class}: #{e.message}"
52
+ end
24
53
  raise ConnectionError, "SMTP connection failed: #{e.message}"
25
54
  end
26
55
  private_class_method :smtp_open
data/lib/mail_mcp/tool.rb CHANGED
@@ -2,5 +2,15 @@ require "mcp"
2
2
 
3
3
  module MailMCP
4
4
  class Tool < MCP::Tool
5
+ def self.format_from(server_context)
6
+ address = server_context.email
7
+ name = server_context.full_name
8
+ return address if name.nil? || name.empty?
9
+
10
+ addr = Mail::Address.new
11
+ addr.address = address
12
+ addr.display_name = name
13
+ addr.format
14
+ end
5
15
  end
6
16
  end
@@ -28,14 +28,15 @@ module MailMCP
28
28
 
29
29
  def self.call(to:, subject:, text_body:, server_context:, cc: nil, bcc: nil, html_body: nil,
30
30
  attachment_urls: [], folder: "Drafts")
31
- imap_config = server_context.imap_config
32
31
  mail = MailBuilder.build(
33
- from: imap_config[:username],
32
+ from: format_from(server_context),
34
33
  to: to, subject: subject, text_body: text_body,
35
34
  cc: cc, bcc: bcc, html_body: html_body,
36
35
  attachment_urls: attachment_urls
37
36
  )
38
- ImapClient.connect(imap_config) { |c| c.append_message(folder: folder, raw_message: mail.to_s) }
37
+ ImapClient.connect(server_context.imap_config) do |c|
38
+ c.append_message(folder: folder, raw_message: mail.to_s, flags: [:Draft])
39
+ end
39
40
  MCP::Tool::Response.new([{ type: "text", text: "Draft saved to #{folder}" }])
40
41
  end
41
42
  end
@@ -30,13 +30,30 @@ module MailMCP
30
30
  def self.call(to:, subject:, text_body:, server_context:, cc: nil, bcc: nil, html_body: nil,
31
31
  attachment_urls: [], folder: "Sent")
32
32
  mail = MailBuilder.build(
33
- from: server_context.imap_config[:username],
33
+ from: format_from(server_context),
34
34
  to: to, subject: subject, text_body: text_body,
35
35
  cc: cc, bcc: bcc, html_body: html_body,
36
36
  attachment_urls: attachment_urls
37
37
  )
38
+ ImapClient.connect(server_context.imap_config) do |c|
39
+ unless c.list_mailboxes.include?(folder)
40
+ raise ImapClient::ConnectionError,
41
+ "IMAP folder #{folder.inspect} does not exist. Use list_mailboxes to see available folders."
42
+ end
43
+ end
44
+
38
45
  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) }
46
+
47
+ begin
48
+ ImapClient.connect(server_context.imap_config) do |c|
49
+ c.append_message(folder: folder, raw_message: mail.to_s, flags: [:Seen])
50
+ end
51
+ rescue StandardError => e
52
+ MailMCP.logger.warn { "Email sent but failed to save to IMAP folder=#{folder.inspect}: #{e.message}" }
53
+ text = "Email sent successfully to #{to}, but could not be saved to #{folder}: #{e.message}"
54
+ return MCP::Tool::Response.new([{ type: "text", text: text }])
55
+ end
56
+
40
57
  MCP::Tool::Response.new([{ type: "text", text: "Email sent successfully to #{to} and saved to #{folder}" }])
41
58
  end
42
59
  end
@@ -10,25 +10,56 @@ module MailMCP
10
10
  open_world_hint: true
11
11
  )
12
12
 
13
+ # IMAP allows only these six backslash-prefixed system flags; anything else
14
+ # must be sent as a keyword (a bare atom with no leading backslash).
15
+ SYSTEM_FLAGS = %w[Seen Answered Flagged Deleted Draft Recent].freeze
16
+
17
+ # Friendly color names map to Mozilla/Thunderbird $labelN keywords, the
18
+ # de-facto cross-client convention for colored flags.
19
+ COLOR_KEYWORDS = {
20
+ "red" => "$label1",
21
+ "orange" => "$label2",
22
+ "green" => "$label3",
23
+ "blue" => "$label4",
24
+ "purple" => "$label5"
25
+ }.freeze
26
+
13
27
  input_schema(
14
28
  type: "object",
15
29
  properties: {
16
30
  folder: { type: "string" },
17
31
  uid: { type: "integer" },
18
32
  add: { type: "array", items: { type: "string" },
19
- description: "Flags to add, e.g. ['\\\\Seen', '\\\\Flagged']" },
33
+ description: "Flags to add. Settable system flags: '\\\\Seen', '\\\\Answered', " \
34
+ "'\\\\Flagged', '\\\\Deleted', '\\\\Draft' (\\\\Recent is recognized " \
35
+ "but cannot be set by clients). Color names " \
36
+ "('red', 'orange', 'green', 'blue', 'purple') map to " \
37
+ "$labelN keywords. Any other value is sent as a custom keyword." },
20
38
  remove: { type: "array", items: { type: "string" }, description: "Flags to remove" }
21
39
  },
22
40
  required: %w[folder uid]
23
41
  )
24
42
 
25
43
  def self.call(folder:, uid:, server_context:, add: [], remove: [])
26
- add_flags = add.map(&:to_sym)
27
- remove_flags = remove.map(&:to_sym)
44
+ add_flags = add.map { |f| normalize_flag(f) }
45
+ remove_flags = remove.map { |f| normalize_flag(f) }
28
46
  ImapClient.connect(server_context.imap_config) do |c|
29
47
  c.update_flags(folder: folder, uid: uid, add: add_flags, remove: remove_flags)
30
48
  end
31
49
  MCP::Tool::Response.new([{ type: "text", text: "Flags updated for message #{uid}" }])
32
50
  end
51
+
52
+ # System flags become Symbols (net/imap renders them with a leading
53
+ # backslash). Custom keywords stay Strings (rendered as bare atoms).
54
+ # Friendly color names are translated to their $labelN keyword first.
55
+ def self.normalize_flag(flag)
56
+ name = flag.to_s.delete_prefix("\\")
57
+ return COLOR_KEYWORDS.fetch(name.downcase) if COLOR_KEYWORDS.key?(name.downcase)
58
+
59
+ # System flags are case-insensitive per RFC 3501; normalize to the
60
+ # canonical capitalization so e.g. "seen" / "\\SEEN" still set :Seen.
61
+ system_flag = SYSTEM_FLAGS.find { |f| f.casecmp?(name) }
62
+ system_flag ? system_flag.to_sym : name
63
+ end
33
64
  end
34
65
  end
@@ -1,3 +1,3 @@
1
1
  module MailMCP
2
- VERSION = "0.1.0".freeze
2
+ VERSION = "1.0.1".freeze
3
3
  end
data/lib/mail_mcp.rb CHANGED
@@ -1,9 +1,17 @@
1
1
  require "base64"
2
2
  require "json"
3
+ require "logger"
3
4
 
4
5
  require_relative "mail_mcp/version"
5
6
 
6
7
  module MailMCP
8
+ def self.logger
9
+ @logger ||= Logger.new($stdout, level: ENV.fetch("MAIL_MCP_LOG_LEVEL", "INFO"), progname: "mail_mcp")
10
+ end
11
+
12
+ class << self
13
+ attr_writer :logger
14
+ end
7
15
  end
8
16
 
9
17
  require_relative "mail_mcp/jwt_service"
@@ -12,6 +20,7 @@ require_relative "mail_mcp/imap_client"
12
20
  require_relative "mail_mcp/smtp_client"
13
21
  require_relative "mail_mcp/attachment_store"
14
22
  require_relative "mail_mcp/credential_context"
23
+ require_relative "mail_mcp/login_form"
15
24
  require_relative "mail_mcp/mail_builder"
16
25
  require_relative "mail_mcp/tool"
17
26
  require_relative "mail_mcp/tools/list_mailboxes_tool"
data/views/login.erb CHANGED
@@ -71,46 +71,64 @@
71
71
  <h1>Mail MCP</h1>
72
72
  <p class="subtitle">Connect your email account to continue</p>
73
73
  <div class="server-info">
74
- IMAP <span><%= @params[:imap_host] %></span> &nbsp;·&nbsp;
75
- SMTP <span><%= @params[:smtp_host] %></span>
74
+ IMAP <span><%= @form.imap_host %></span> &nbsp;·&nbsp;
75
+ SMTP <span><%= @form.smtp_host %></span>
76
76
  </div>
77
77
 
78
- <% if @params[:error] %>
79
- <div class="error"><%= @params[:error] %></div>
78
+ <% unless @form.errors.empty? %>
79
+ <div class="error">
80
+ <% @form.errors.each do |err| %>
81
+ <div><%= err %></div>
82
+ <% end %>
83
+ </div>
80
84
  <% end %>
81
85
 
82
86
  <form method="POST" action="/oauth/authorize">
83
- <input type="hidden" name="client_id" value="<%= @params[:client_id] %>">
84
- <input type="hidden" name="redirect_uri" value="<%= @params[:redirect_uri] %>">
85
- <input type="hidden" name="state" value="<%= @params[:state] %>">
86
- <input type="hidden" name="code_challenge" value="<%= @params[:code_challenge] %>">
87
- <input type="hidden" name="code_challenge_method" value="<%= @params[:code_challenge_method] %>">
87
+ <input type="hidden" name="client_id" value="<%= @form.client_id %>">
88
+ <input type="hidden" name="redirect_uri" value="<%= @form.redirect_uri %>">
89
+ <input type="hidden" name="state" value="<%= @form.state %>">
90
+ <input type="hidden" name="code_challenge" value="<%= @form.code_challenge %>">
91
+ <input type="hidden" name="code_challenge_method" value="<%= @form.code_challenge_method %>">
92
+
93
+ <label for="full_name">Full Name</label>
94
+ <input type="text" id="full_name" name="full_name"
95
+ placeholder="John Doe" autocomplete="name"
96
+ value="<%= escape_html(@form.full_name) %>">
97
+
98
+ <label for="email">Email Address</label>
99
+ <input type="text" id="email" name="email"
100
+ placeholder="you@example.com" autocomplete="email" required
101
+ value="<%= escape_html(@form.email) %>">
88
102
 
89
103
  <div class="section-title">IMAP (Incoming)</div>
90
104
 
91
105
  <label for="imap_username">IMAP Username</label>
92
106
  <input type="text" id="imap_username" name="imap_username"
93
- placeholder="you@example.com" autocomplete="username" required>
107
+ placeholder="you@example.com" autocomplete="username" required
108
+ value="<%= escape_html(@form.imap_username) %>">
94
109
 
95
110
  <label for="imap_password">IMAP Password</label>
96
111
  <input type="password" id="imap_password" name="imap_password"
97
- placeholder="••••••••" autocomplete="current-password" required>
112
+ placeholder="••••••••" autocomplete="current-password" required
113
+ value="<%= escape_html(@form.imap_password) %>">
98
114
 
99
115
  <div class="checkbox-row">
100
- <input type="checkbox" id="use_same" name="use_same_credentials" value="1" checked>
116
+ <input type="checkbox" id="use_same" name="use_same_credentials" value="1" <%= "checked" if @form.use_same_credentials? %>>
101
117
  <label for="use_same">Use the same username and password for SMTP</label>
102
118
  </div>
103
119
 
104
- <div id="smtp-fields" style="display:none">
120
+ <div id="smtp-fields" style="display:<%= @form.use_same_credentials? ? "none" : "block" %>">
105
121
  <div class="section-title">SMTP (Outgoing)</div>
106
122
 
107
123
  <label for="smtp_username">SMTP Username</label>
108
124
  <input type="text" id="smtp_username" name="smtp_username"
109
- placeholder="you@example.com" autocomplete="username">
125
+ placeholder="you@example.com" autocomplete="username"
126
+ value="<%= escape_html(@form.smtp_username) %>">
110
127
 
111
128
  <label for="smtp_password">SMTP Password</label>
112
129
  <input type="password" id="smtp_password" name="smtp_password"
113
- placeholder="••••••••" autocomplete="current-password">
130
+ placeholder="••••••••" autocomplete="current-password"
131
+ value="<%= escape_html(@form.smtp_password) %>">
114
132
  </div>
115
133
 
116
134
  <button type="submit">Connect</button>
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mail_mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Edgars Beigarts
@@ -158,6 +158,7 @@ executables:
158
158
  extensions: []
159
159
  extra_rdoc_files: []
160
160
  files:
161
+ - LICENSE
161
162
  - README.md
162
163
  - bin/mail_mcp
163
164
  - config.ru
@@ -168,6 +169,7 @@ files:
168
169
  - lib/mail_mcp/credential_context.rb
169
170
  - lib/mail_mcp/imap_client.rb
170
171
  - lib/mail_mcp/jwt_service.rb
172
+ - lib/mail_mcp/login_form.rb
171
173
  - lib/mail_mcp/mail_builder.rb
172
174
  - lib/mail_mcp/pkce.rb
173
175
  - lib/mail_mcp/smtp_client.rb
@@ -183,10 +185,13 @@ files:
183
185
  - lib/mail_mcp/tools/update_mail_message_flags_tool.rb
184
186
  - lib/mail_mcp/version.rb
185
187
  - views/login.erb
188
+ homepage: https://github.com/mitigate-dev/mail_mcp
186
189
  licenses:
187
190
  - MIT
188
191
  metadata:
189
192
  rubygems_mfa_required: 'true'
193
+ homepage_uri: https://github.com/mitigate-dev/mail_mcp
194
+ source_code_uri: https://github.com/mitigate-dev/mail_mcp
190
195
  rdoc_options: []
191
196
  require_paths:
192
197
  - lib