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 +4 -4
- data/LICENSE +21 -0
- data/README.md +41 -38
- data/bin/mail_mcp +2 -2
- data/lib/mail_mcp/app.rb +14 -69
- data/lib/mail_mcp/credential_context.rb +1 -1
- data/lib/mail_mcp/imap_client.rb +73 -22
- data/lib/mail_mcp/jwt_service.rb +1 -0
- data/lib/mail_mcp/login_form.rb +72 -0
- data/lib/mail_mcp/smtp_client.rb +31 -2
- data/lib/mail_mcp/tool.rb +10 -0
- data/lib/mail_mcp/tools/create_draft_mail_message_tool.rb +4 -3
- data/lib/mail_mcp/tools/send_mail_message_tool.rb +19 -2
- data/lib/mail_mcp/tools/update_mail_message_flags_tool.rb +34 -3
- data/lib/mail_mcp/version.rb +1 -1
- data/lib/mail_mcp.rb +9 -0
- data/views/login.erb +33 -15
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c33a2c64482699085c980befb438655c2d599512422281bd3c3cb48a32582500
|
|
4
|
+
data.tar.gz: 3dfbfeba54ef8f83d3bf99c7a727f7207c7245032c5df8eb6fbb53af4c089fb7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
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
|
|
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
|
+

|
|
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
|
|
44
|
-
|
|
45
|
-
| `client_id`
|
|
46
|
-
| Access token
|
|
47
|
-
| Refresh token | `refresh`
|
|
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
|
|
83
|
-
|
|
84
|
-
| `BASE_URL`
|
|
85
|
-
| `ENCRYPTION_KEY`
|
|
86
|
-
| `AWS_ACCESS_KEY_ID`
|
|
87
|
-
| `AWS_SECRET_ACCESS_KEY` | AWS credentials for S3 attachment storage
|
|
88
|
-
| `AWS_REGION`
|
|
89
|
-
| `AWS_S3_BUCKET`
|
|
90
|
-
| `PORT`
|
|
91
|
-
| `RACK_ENV`
|
|
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=
|
|
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:
|
|
135
|
+
# SMTP: smtp.gmail.com:465 (ssl=true)
|
|
133
136
|
```
|
|
134
137
|
|
|
135
|
-
| Flag
|
|
136
|
-
|
|
137
|
-
| `--imap-host=HOST` | required
|
|
138
|
-
| `--imap-port=PORT` | `993`
|
|
139
|
-
| `--[no-]imap-ssl`
|
|
140
|
-
| `--smtp-host=HOST` | required
|
|
141
|
-
| `--smtp-port=PORT` | `
|
|
142
|
-
| `--[no-]smtp-ssl`
|
|
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
|
|
156
|
-
|
|
157
|
-
| `list_mailboxes`
|
|
158
|
-
| `list_mail_messages`
|
|
159
|
-
| `get_mail_message`
|
|
160
|
-
| `search_mail_messages`
|
|
161
|
-
| `send_mail_message`
|
|
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`
|
|
164
|
-
| `move_mail_message`
|
|
165
|
-
| `update_mail_message_flags` | `folder`, `uid`, `add`, `remove`
|
|
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:
|
|
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"] ||
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
data/lib/mail_mcp/imap_client.rb
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"]
|
data/lib/mail_mcp/jwt_service.rb
CHANGED
|
@@ -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
|
data/lib/mail_mcp/smtp_client.rb
CHANGED
|
@@ -10,17 +10,46 @@ module MailMCP
|
|
|
10
10
|
|
|
11
11
|
def self.send(config, mail)
|
|
12
12
|
smtp_open(config) do |s|
|
|
13
|
-
|
|
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.
|
|
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:
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
27
|
-
remove_flags = remove.map(
|
|
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
|
data/lib/mail_mcp/version.rb
CHANGED
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><%= @
|
|
75
|
-
SMTP <span><%= @
|
|
74
|
+
IMAP <span><%= @form.imap_host %></span> ·
|
|
75
|
+
SMTP <span><%= @form.smtp_host %></span>
|
|
76
76
|
</div>
|
|
77
77
|
|
|
78
|
-
<%
|
|
79
|
-
<div class="error"
|
|
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="<%= @
|
|
84
|
-
<input type="hidden" name="redirect_uri" value="<%= @
|
|
85
|
-
<input type="hidden" name="state" value="<%= @
|
|
86
|
-
<input type="hidden" name="code_challenge" value="<%= @
|
|
87
|
-
<input type="hidden" name="code_challenge_method" value="<%= @
|
|
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
|
|
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
|
|
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
|