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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 52c052a557e253241811bc0b5342a6980846ab1475761ea9b337410ed5488ed3
|
|
4
|
+
data.tar.gz: 4e8ab0e13a25ba3ab8eea26228a55d284bcd2dc4ca403a42e695585e56253643
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5f47ac7a7114328a2cd8b10febe15fcd1d742f0d3ebdbbcb48556ce9a71ff7ca65af36ce5edeafc719bc8a279dbc4366132d1e56fe514347d7d16dbd952d30c0
|
|
7
|
+
data.tar.gz: 03f3ae4b06d27920d667f767757eaa6f2bcaf7bc0955f8774f4f6fe457b29247760ea65203e078979025c3420b4ce5ca27f055a73e629e1807582e1791aba555
|
data/README.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Mitigate Mail MCP
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Client (Claude Desktop / MCP Inspector)
|
|
9
|
+
│
|
|
10
|
+
├─ OAuth 2.1 flow
|
|
11
|
+
│ GET /.well-known/oauth-protected-resource RFC 9728 metadata
|
|
12
|
+
│ GET /.well-known/oauth-authorization-server RFC 8414 metadata
|
|
13
|
+
│ GET /oauth/authorize Login UI
|
|
14
|
+
│ POST /oauth/authorize Validate IMAP/SMTP → issue code
|
|
15
|
+
│ POST /oauth/token Code + PKCE + client_secret → tokens
|
|
16
|
+
│ (also: refresh_token grant)
|
|
17
|
+
│
|
|
18
|
+
└─ MCP calls (all HTTP methods)
|
|
19
|
+
/mcp Bearer access token → decrypt creds → IMAP/SMTP operations
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### How authentication works
|
|
23
|
+
|
|
24
|
+
Clients are provisioned once via the `bin/mail_mcp generate` CLI, which produces:
|
|
25
|
+
|
|
26
|
+
- **`client_id`** — a JWE token (encrypted, opaque) encoding the IMAP/SMTP server configuration and the `client_secret`. Only the server can decrypt it.
|
|
27
|
+
- **`client_secret`** — a random secret used to authenticate the client at the token endpoint.
|
|
28
|
+
|
|
29
|
+
The OAuth flow:
|
|
30
|
+
|
|
31
|
+
1. Client redirects the user to `/oauth/authorize?client_id=<jwe>&...`
|
|
32
|
+
2. Server decrypts the `client_id` JWE to learn which IMAP/SMTP servers to connect to
|
|
33
|
+
3. User enters their IMAP/SMTP username and password in the login form
|
|
34
|
+
4. Server validates both connections live; shows an error banner on failure
|
|
35
|
+
5. On success: credentials are encrypted directly in a JWE access token and a JWE refresh token
|
|
36
|
+
6. Client exchanges the authorization code (`POST /oauth/token`) with `client_id` + `client_secret`; receives `access_token` + `refresh_token`
|
|
37
|
+
7. Every MCP request carries `Authorization: Bearer <access_token>`; the server decrypts credentials per-request
|
|
38
|
+
|
|
39
|
+
### Token formats
|
|
40
|
+
|
|
41
|
+
All tokens are **5-part JWE** (`dir` / `A256GCM`), encrypted with `ENCRYPTION_KEY`. There is no separate signing key.
|
|
42
|
+
|
|
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 |
|
|
48
|
+
|
|
49
|
+
## Directory Structure
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
mail_mcp/
|
|
53
|
+
├── Gemfile
|
|
54
|
+
├── .env.sample # Environment variable template
|
|
55
|
+
├── bin/
|
|
56
|
+
│ └── mail_mcp # CLI: `generate` (client_id) and `server` (puma)
|
|
57
|
+
├── config.ru # Rack entry point — run MailMCP::App.new
|
|
58
|
+
├── config/
|
|
59
|
+
│ └── puma.rb # Puma config (single worker, 5 threads)
|
|
60
|
+
├── lib/
|
|
61
|
+
│ ├── mail_mcp.rb # Module root + requires
|
|
62
|
+
│ └── mail_mcp/
|
|
63
|
+
│ ├── jwt_service.rb # All JWE tokens (access, refresh, client_id)
|
|
64
|
+
│ ├── pkce.rb # PKCE S256 challenge/verify
|
|
65
|
+
│ ├── credential_context.rb # Struct passed as MCP server_context per request
|
|
66
|
+
│ ├── imap_client.rb # net-imap wrapper
|
|
67
|
+
│ ├── smtp_client.rb # net-smtp wrapper
|
|
68
|
+
│ ├── attachment_store.rb # S3 upload + presigned URLs (7 days)
|
|
69
|
+
│ ├── tool.rb # MailMCP::Tool base class
|
|
70
|
+
│ ├── app.rb # Sinatra: OAuth + MCP /mcp route (all methods)
|
|
71
|
+
│ └── tools/*.rb # MCP tool classes
|
|
72
|
+
├── views/
|
|
73
|
+
│ └── login.erb # Login form (username + password only)
|
|
74
|
+
├── spec/ # RSpec test suite
|
|
75
|
+
└── Dockerfile
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Configuration
|
|
79
|
+
|
|
80
|
+
Copy `.env.sample` to `.env` and fill in the values:
|
|
81
|
+
|
|
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` |
|
|
92
|
+
|
|
93
|
+
Generate `ENCRYPTION_KEY`:
|
|
94
|
+
```bash
|
|
95
|
+
ruby -e "require 'base64','securerandom'; puts Base64.strict_encode64(SecureRandom.bytes(32))"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
IMAP/SMTP host and port are embedded in the `client_id` JWE and are never passed as headers or query parameters.
|
|
99
|
+
|
|
100
|
+
## Setup
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
# Install dependencies
|
|
104
|
+
bundle install
|
|
105
|
+
|
|
106
|
+
# Copy and edit environment variables
|
|
107
|
+
cp .env.sample .env
|
|
108
|
+
$EDITOR .env
|
|
109
|
+
|
|
110
|
+
# Run tests
|
|
111
|
+
bundle exec rspec
|
|
112
|
+
|
|
113
|
+
# Start the server
|
|
114
|
+
bundle exec puma -C config/puma.rb
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Provisioning a Client
|
|
118
|
+
|
|
119
|
+
Run `bin/mail_mcp generate` once per mail server configuration. The resulting `client_id` and `client_secret` are configured in the MCP client (e.g. Claude Desktop).
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
bundle exec bin/mail_mcp generate \
|
|
123
|
+
--imap-host=imap.gmail.com \
|
|
124
|
+
--imap-port=993 \
|
|
125
|
+
--smtp-host=smtp.gmail.com \
|
|
126
|
+
--smtp-port=587
|
|
127
|
+
|
|
128
|
+
# Client ID: eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0...<encrypted>
|
|
129
|
+
# Client Secret: 713576e2f94802b9d9abfd755e38e29b63e491df...
|
|
130
|
+
#
|
|
131
|
+
# IMAP: imap.gmail.com:993 (ssl=true)
|
|
132
|
+
# SMTP: smtp.gmail.com:587 (ssl=false)
|
|
133
|
+
```
|
|
134
|
+
|
|
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 |
|
|
143
|
+
|
|
144
|
+
## OAuth 2.1 Flow
|
|
145
|
+
|
|
146
|
+
1. **Discovery** — client fetches `/.well-known/oauth-protected-resource` and `/.well-known/oauth-authorization-server`
|
|
147
|
+
2. **Authorization** — client redirects user to `/oauth/authorize?client_id=<jwe>&code_challenge=<s256>&...`
|
|
148
|
+
3. **Login** — server decrypts `client_id` JWE to get IMAP/SMTP hosts; user enters credentials; server validates both connections live
|
|
149
|
+
4. **Code exchange** — `POST /oauth/token` with `grant_type=authorization_code`, `code`, `code_verifier`, `client_id`, `client_secret`; server issues `access_token` + `refresh_token`
|
|
150
|
+
5. **Token refresh** — `POST /oauth/token` with `grant_type=refresh_token`, `refresh_token`, `client_id`, `client_secret`; server issues a new `access_token` + `refresh_token`
|
|
151
|
+
6. **MCP calls** — client sends `Authorization: Bearer <access_token>` on every request; server decrypts credentials per-request via a stateless per-request MCP server
|
|
152
|
+
|
|
153
|
+
## MCP Tools
|
|
154
|
+
|
|
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` |
|
|
166
|
+
|
|
167
|
+
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
|
+
|
|
169
|
+
## Docker
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
docker build -t mail_mcp .
|
|
173
|
+
docker run -p 3000:3000 --env-file .env mail_mcp
|
|
174
|
+
```
|
data/bin/mail_mcp
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
require "optparse"
|
|
3
|
+
|
|
4
|
+
COMMANDS = {
|
|
5
|
+
"generate" => "Generate a new client_id and client_secret",
|
|
6
|
+
"server" => "Start the MCP server (puma)"
|
|
7
|
+
}.freeze
|
|
8
|
+
|
|
9
|
+
def print_usage
|
|
10
|
+
warn "Usage: mail_mcp <command> [options]"
|
|
11
|
+
warn ""
|
|
12
|
+
warn "Commands:"
|
|
13
|
+
COMMANDS.each { |name, desc| warn format(" %-10<name>s %<desc>s", name: name, desc: desc) }
|
|
14
|
+
warn ""
|
|
15
|
+
warn "Run `mail_mcp <command> --help` for command-specific options."
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def load_app_env
|
|
19
|
+
if ENV["RACK_ENV"] != "production"
|
|
20
|
+
require "dotenv"
|
|
21
|
+
Dotenv.load(File.expand_path("../.env", __dir__))
|
|
22
|
+
end
|
|
23
|
+
require_relative "../lib/mail_mcp"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def generate_option_parser(options)
|
|
27
|
+
OptionParser.new do |opts|
|
|
28
|
+
opts.banner = "Usage: mail_mcp generate [options]"
|
|
29
|
+
opts.separator ""
|
|
30
|
+
opts.separator "Required:"
|
|
31
|
+
opts.on("--imap-host=HOST", "IMAP server hostname, e.g. imap.gmail.com")
|
|
32
|
+
opts.on("--imap-port=PORT", Integer, "IMAP port (default: 993)")
|
|
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)")
|
|
35
|
+
opts.separator ""
|
|
36
|
+
opts.separator "Optional:"
|
|
37
|
+
opts.on("--[no-]imap-ssl", "Enable SSL/TLS for IMAP (default: true when port 993)")
|
|
38
|
+
opts.on("--[no-]smtp-ssl", "Enable SSL/TLS for SMTP (default: true when port 465)")
|
|
39
|
+
opts.separator ""
|
|
40
|
+
opts.on("-h", "--help", "Show this help") do
|
|
41
|
+
puts opts
|
|
42
|
+
exit
|
|
43
|
+
end
|
|
44
|
+
opts.parse!(ARGV, into: options)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def normalize_generate_options(options, parser)
|
|
49
|
+
imap_host = options[:"imap-host"]
|
|
50
|
+
smtp_host = options[:"smtp-host"]
|
|
51
|
+
unless imap_host && smtp_host
|
|
52
|
+
warn "Error: --imap-host and --smtp-host are required"
|
|
53
|
+
warn ""
|
|
54
|
+
warn parser
|
|
55
|
+
exit 1
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
imap_port = options[:"imap-port"] || 993
|
|
59
|
+
smtp_port = options[:"smtp-port"] || 587
|
|
60
|
+
{
|
|
61
|
+
imap_host: imap_host,
|
|
62
|
+
imap_port: imap_port,
|
|
63
|
+
imap_ssl: options.fetch(:"imap-ssl", imap_port == 993),
|
|
64
|
+
smtp_host: smtp_host,
|
|
65
|
+
smtp_port: smtp_port,
|
|
66
|
+
smtp_ssl: options.fetch(:"smtp-ssl", smtp_port == 465)
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def print_generated_client(opts, client_id, client_secret)
|
|
71
|
+
puts ""
|
|
72
|
+
puts " Client ID: #{client_id}"
|
|
73
|
+
puts " Client Secret: #{client_secret}"
|
|
74
|
+
puts ""
|
|
75
|
+
puts " IMAP: #{opts[:imap_host]}:#{opts[:imap_port]} (ssl=#{opts[:imap_ssl]})"
|
|
76
|
+
puts " SMTP: #{opts[:smtp_host]}:#{opts[:smtp_port]} (ssl=#{opts[:smtp_ssl]})"
|
|
77
|
+
puts ""
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def run_generate
|
|
81
|
+
load_app_env
|
|
82
|
+
options = {}
|
|
83
|
+
parser = generate_option_parser(options)
|
|
84
|
+
opts = normalize_generate_options(options, parser)
|
|
85
|
+
|
|
86
|
+
client_secret = SecureRandom.hex(32)
|
|
87
|
+
client_id = MailMCP::JwtService.issue_client_id(**opts, client_secret: client_secret)
|
|
88
|
+
print_generated_client(opts, client_id, client_secret)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def run_server(argv)
|
|
92
|
+
require "puma/cli"
|
|
93
|
+
config = File.expand_path("../config/puma.rb", __dir__)
|
|
94
|
+
rackup = File.expand_path("../config.ru", __dir__)
|
|
95
|
+
Puma::CLI.new(["-C", config, rackup, *argv]).run
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
command = ARGV.shift
|
|
99
|
+
|
|
100
|
+
case command
|
|
101
|
+
when "generate"
|
|
102
|
+
run_generate
|
|
103
|
+
when "server"
|
|
104
|
+
run_server(ARGV)
|
|
105
|
+
when "-h", "--help", nil
|
|
106
|
+
print_usage
|
|
107
|
+
exit(command.nil? ? 1 : 0)
|
|
108
|
+
else
|
|
109
|
+
warn "Unknown command: #{command}"
|
|
110
|
+
warn ""
|
|
111
|
+
print_usage
|
|
112
|
+
exit 1
|
|
113
|
+
end
|
data/config/puma.rb
ADDED
data/config.ru
ADDED
data/lib/mail_mcp/app.rb
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
require "sinatra/base"
|
|
2
|
+
require "sinatra/json"
|
|
3
|
+
require "sinatra/multi_route"
|
|
4
|
+
require "mcp"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module MailMCP
|
|
8
|
+
class App < Sinatra::Base
|
|
9
|
+
register Sinatra::MultiRoute
|
|
10
|
+
|
|
11
|
+
set :views, File.expand_path("../../views", __dir__)
|
|
12
|
+
set :public_folder, false
|
|
13
|
+
set :logging, true
|
|
14
|
+
|
|
15
|
+
MCP_TOOLS = [
|
|
16
|
+
ListMailboxesTool,
|
|
17
|
+
ListMailMessagesTool,
|
|
18
|
+
GetMailMessageTool,
|
|
19
|
+
SearchMailMessagesTool,
|
|
20
|
+
SendMailMessageTool,
|
|
21
|
+
CreateDraftMailMessageTool,
|
|
22
|
+
DeleteMailMessageTool,
|
|
23
|
+
MoveMailMessageTool,
|
|
24
|
+
UpdateMailMessageFlagsTool
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
# ── MCP ──────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
route :head, :delete, :get, :options, :patch, :post, :put, "/mcp" do
|
|
30
|
+
server_context, error_response = resolve_mcp_context
|
|
31
|
+
halt(*error_response) if error_response
|
|
32
|
+
|
|
33
|
+
mcp_server = MCP::Server.new(
|
|
34
|
+
name: "mail_mcp",
|
|
35
|
+
version: "1.0.0",
|
|
36
|
+
description: "IMAP and SMTP mail server for AI agents",
|
|
37
|
+
tools: MCP_TOOLS,
|
|
38
|
+
server_context: server_context
|
|
39
|
+
)
|
|
40
|
+
transport = MCP::Server::Transports::StreamableHTTPTransport.new(mcp_server, stateless: true)
|
|
41
|
+
status_code, resp_headers, body = transport.call(env)
|
|
42
|
+
halt status_code, resp_headers, body
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# ── OAuth 2.1 Discovery ───────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
get "/.well-known/oauth-protected-resource" do
|
|
48
|
+
content_type :json
|
|
49
|
+
JSON.generate({
|
|
50
|
+
resource: base_url,
|
|
51
|
+
authorization_servers: [base_url]
|
|
52
|
+
})
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
get "/.well-known/oauth-authorization-server" do
|
|
56
|
+
content_type :json
|
|
57
|
+
JSON.generate({
|
|
58
|
+
issuer: base_url,
|
|
59
|
+
authorization_endpoint: "#{base_url}/oauth/authorize",
|
|
60
|
+
token_endpoint: "#{base_url}/oauth/token",
|
|
61
|
+
response_types_supported: ["code"],
|
|
62
|
+
grant_types_supported: ["authorization_code"],
|
|
63
|
+
code_challenge_methods_supported: ["S256"]
|
|
64
|
+
})
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# ── Authorization (Login UI) ──────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
get "/oauth/authorize" do
|
|
70
|
+
client_config = decode_client_id!(params[:client_id])
|
|
71
|
+
return if client_config.nil?
|
|
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
|
+
}
|
|
83
|
+
erb :login
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
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)
|
|
94
|
+
return if client_config.nil?
|
|
95
|
+
|
|
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
|
+
}
|
|
141
|
+
|
|
142
|
+
code = JwtService.issue_code(
|
|
143
|
+
creds: creds,
|
|
144
|
+
code_challenge: code_challenge,
|
|
145
|
+
redirect_uri: redirect_uri,
|
|
146
|
+
client_id: client_id
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
redirect_to = URI.parse(redirect_uri)
|
|
150
|
+
query = URI.decode_www_form(redirect_to.query.to_s)
|
|
151
|
+
query << ["code", code]
|
|
152
|
+
query << ["state", state] if state
|
|
153
|
+
redirect_to.query = URI.encode_www_form(query)
|
|
154
|
+
redirect redirect_to.to_s
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# ── Token Exchange ────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
post "/oauth/token" do
|
|
160
|
+
content_type :json
|
|
161
|
+
|
|
162
|
+
grant_type = params[:grant_type]
|
|
163
|
+
client_id = params[:client_id]
|
|
164
|
+
client_secret = params[:client_secret]
|
|
165
|
+
|
|
166
|
+
client_config = begin
|
|
167
|
+
JwtService.decode_client_id(client_id.to_s)
|
|
168
|
+
rescue JwtService::Error
|
|
169
|
+
halt 401, JSON.generate({ error: "invalid_client", error_description: "Invalid client_id" })
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
unless client_secret.to_s == client_config["cs"]
|
|
173
|
+
halt 401, JSON.generate({ error: "invalid_client", error_description: "Invalid client_secret" })
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
creds = case grant_type
|
|
177
|
+
when "authorization_code"
|
|
178
|
+
exchange_code(params, client_id)
|
|
179
|
+
when "refresh_token"
|
|
180
|
+
exchange_refresh_token(params[:refresh_token])
|
|
181
|
+
else
|
|
182
|
+
halt 400, JSON.generate({ error: "unsupported_grant_type" })
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
token_response(creds)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# ── Health ────────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
get "/health" do
|
|
191
|
+
content_type :json
|
|
192
|
+
JSON.generate({ status: "ok" })
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
not_found do
|
|
196
|
+
content_type :json
|
|
197
|
+
JSON.generate({ error: "not_found" })
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
private
|
|
201
|
+
|
|
202
|
+
def resolve_mcp_context
|
|
203
|
+
auth = request.env["HTTP_AUTHORIZATION"]
|
|
204
|
+
return [nil, nil] unless auth&.start_with?("Bearer ")
|
|
205
|
+
|
|
206
|
+
creds = JwtService.verify(auth[7..])
|
|
207
|
+
context = CredentialContext.new(
|
|
208
|
+
imap_config: {
|
|
209
|
+
host: creds["imap_host"], port: creds["imap_port"].to_i,
|
|
210
|
+
ssl: creds["imap_ssl"], username: creds["imap_username"], password: creds["imap_password"]
|
|
211
|
+
},
|
|
212
|
+
smtp_config: {
|
|
213
|
+
host: creds["smtp_host"], port: creds["smtp_port"].to_i,
|
|
214
|
+
ssl: creds["smtp_ssl"], username: creds["smtp_username"], password: creds["smtp_password"]
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
[context, nil]
|
|
218
|
+
rescue JwtService::Error => e
|
|
219
|
+
error = [
|
|
220
|
+
401,
|
|
221
|
+
{ "Content-Type" => "application/json", "WWW-Authenticate" => mcp_www_authenticate },
|
|
222
|
+
[JSON.generate({ error: "invalid_token", error_description: e.message })]
|
|
223
|
+
]
|
|
224
|
+
[nil, error]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def exchange_code(params, client_id)
|
|
228
|
+
code = params[:code]
|
|
229
|
+
code_verifier = params[:code_verifier]
|
|
230
|
+
redirect_uri = params[:redirect_uri]
|
|
231
|
+
|
|
232
|
+
payload = begin
|
|
233
|
+
JwtService.verify_code(code.to_s)
|
|
234
|
+
rescue JwtService::Error => e
|
|
235
|
+
halt 400, JSON.generate({ error: "invalid_grant", error_description: e.message })
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
if payload["redirect_uri"] != redirect_uri
|
|
239
|
+
halt 400,
|
|
240
|
+
JSON.generate({ error: "invalid_grant",
|
|
241
|
+
error_description: "redirect_uri mismatch" })
|
|
242
|
+
end
|
|
243
|
+
halt 400, JSON.generate({ error: "invalid_client" }) if payload["client_id"] != client_id
|
|
244
|
+
unless Pkce.valid?(verifier: code_verifier.to_s, challenge: payload["code_challenge"].to_s)
|
|
245
|
+
halt 400, JSON.generate({ error: "invalid_grant", error_description: "PKCE verification failed" })
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
payload.slice(*JwtService::CRED_KEYS)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def exchange_refresh_token(refresh_token)
|
|
252
|
+
JwtService.verify_refresh(refresh_token.to_s)
|
|
253
|
+
rescue JwtService::Error => e
|
|
254
|
+
halt 400, JSON.generate({ error: "invalid_grant", error_description: e.message })
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def token_response(creds)
|
|
258
|
+
JSON.generate({
|
|
259
|
+
access_token: JwtService.issue(creds),
|
|
260
|
+
refresh_token: JwtService.issue_refresh(creds),
|
|
261
|
+
token_type: "Bearer",
|
|
262
|
+
expires_in: JwtService::DEFAULT_EXPIRY
|
|
263
|
+
})
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def mcp_www_authenticate
|
|
267
|
+
"Bearer resource_metadata=\"#{base_url}/.well-known/oauth-protected-resource\""
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def base_url
|
|
271
|
+
ENV.fetch("BASE_URL")
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def decode_client_id!(client_id)
|
|
275
|
+
JwtService.decode_client_id(client_id.to_s)
|
|
276
|
+
rescue JwtService::Error => e
|
|
277
|
+
halt 400, "Invalid client_id: #{e.message}"
|
|
278
|
+
nil
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require "aws-sdk-s3"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
|
|
4
|
+
module MailMCP
|
|
5
|
+
module AttachmentStore
|
|
6
|
+
EXPIRY = 7 * 24 * 3600
|
|
7
|
+
|
|
8
|
+
def self.upload(content:, filename:, content_type:)
|
|
9
|
+
key = "attachments/#{SecureRandom.uuid}/#{filename}"
|
|
10
|
+
bucket = ENV.fetch("AWS_S3_BUCKET")
|
|
11
|
+
|
|
12
|
+
s3.put_object(
|
|
13
|
+
bucket: bucket,
|
|
14
|
+
key: key,
|
|
15
|
+
body: content,
|
|
16
|
+
content_type: content_type
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
presigner.presigned_url(:get_object, bucket: bucket, key: key, expires_in: EXPIRY)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.s3
|
|
23
|
+
Aws::S3::Client.new
|
|
24
|
+
end
|
|
25
|
+
private_class_method :s3
|
|
26
|
+
|
|
27
|
+
def self.presigner
|
|
28
|
+
Aws::S3::Presigner.new(client: s3)
|
|
29
|
+
end
|
|
30
|
+
private_class_method :presigner
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
module MailMCP
|
|
2
|
+
# Immutable credential value object passed as server_context to each per-request MCP server.
|
|
3
|
+
# MCP::ServerContext delegates imap_config / smtp_config to this via method_missing.
|
|
4
|
+
CredentialContext = Struct.new(:imap_config, :smtp_config, keyword_init: true)
|
|
5
|
+
end
|