mailcatcher-ng 1.4.0 → 1.5.2

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: 6539793c4e31686b97ba0502cc4c48c47c49ace16230fea4cf2f2cb8d06704c7
4
- data.tar.gz: d74ed846c8205f2b37b9b433b618424929d12515a928d23b4a481fe03a5993d1
3
+ metadata.gz: 69f0a5c91a168bb7b409d219e6419a404c80a1623184b3899e58527562bd9a43
4
+ data.tar.gz: 0b849b98f7b13db2022a49edf28ca88015521d4426db272b3fcc3355f95bc587
5
5
  SHA512:
6
- metadata.gz: fd19f4d57d9710adc6c60a21aeeefbfb2623e57d6725463f5bd01b3ce5e5fcb93360d1543301bc9655b64fc814994d036e3c8cf8de3940643f69ccf1f4063297
7
- data.tar.gz: 7aed22f4e9a54ca7e239f7401d95931cfce90f309d2beae5d28049aa939b4903bb2d256aac8ff694c3740bf62835d6bf2c5f90d8c9b53fd3f855a2b282e6860e
6
+ metadata.gz: 7de12fb6e7d6816804161880de898a35f887647be32e4a776df5b852236ad567577964533896a5e8580d68e75b5315a049658cd33cc3bae6059c12b1c742b454
7
+ data.tar.gz: 0bbb9881849597362e8db7ed8151a06ad793417f7bbeced87286a04d8226eb5d4094db73d039f9334cb02da7d82ce3e597478f82f277da2070856e9657917c21
data/README.md CHANGED
@@ -14,12 +14,17 @@ MailCatcher NG runs a super simple SMTP server which catches any message sent to
14
14
 
15
15
  - [Quick Start](#quick-start)
16
16
  - [Features](#features)
17
+ - [Claude Integration](#claude-integration)
17
18
  - [Documentation](#documentation)
18
19
  - [Installation & Setup](reference/INSTALLATION.md)
19
20
  - [Usage & Configuration](reference/USAGE.md)
20
21
  - [Framework Integration](reference/INTEGRATIONS.md)
21
22
  - [REST API](reference/API.md)
22
23
  - [Advanced Features](reference/ADVANCED.md)
24
+ - [Claude Integration Guide](CLAUDE_INTEGRATION.md)
25
+ - [Claude Plugin Setup](reference/CLAUDE_PLUGIN_SETUP.md)
26
+ - [MCP Server Setup](reference/MCP_SETUP.md)
27
+ - [Integration Architecture](reference/INTEGRATION_ARCHITECTURE.md)
23
28
  - [Credits](reference/CREDITS.md)
24
29
  - [License](#license)
25
30
 
@@ -51,6 +56,55 @@ MailCatcher NG runs a super simple SMTP server which catches any message sent to
51
56
 
52
57
  For a comprehensive list of all features, see [FEATURES.md](FEATURES.md).
53
58
 
59
+ ## Claude Integration
60
+
61
+ MailCatcher NG integrates seamlessly with Claude through two complementary methods:
62
+
63
+ ### Claude Plugin (Easiest)
64
+
65
+ Use MailCatcher with Claude.com or Claude Desktop without any installation:
66
+
67
+ ```bash
68
+ mailcatcher --plugin --foreground
69
+ ```
70
+
71
+ Then add the plugin in Claude settings: `http://localhost:1080/.well-known/ai-plugin.json`
72
+
73
+ ### MCP Server (Programmatic)
74
+
75
+ Enable programmatic access via Model Context Protocol:
76
+
77
+ ```bash
78
+ mailcatcher --mcp --foreground
79
+ ```
80
+
81
+ Configure in Claude Desktop's `~/.claude_desktop_config.json`:
82
+
83
+ ```json
84
+ {
85
+ "mcpServers": {
86
+ "mailcatcher": {
87
+ "command": "mailcatcher",
88
+ "args": ["--mcp", "--foreground"]
89
+ }
90
+ }
91
+ }
92
+ ```
93
+
94
+ ### Available Tools
95
+
96
+ Both methods expose 7 powerful tools:
97
+
98
+ - **search_messages** - Full-text search with filtering
99
+ - **get_latest_message_for** - Find latest message for recipient
100
+ - **extract_token_or_link** - Extract OTPs, magic links, reset tokens
101
+ - **get_parsed_auth_info** - Structured authentication data
102
+ - **get_message_preview_html** - Responsive HTML preview
103
+ - **delete_message** - Delete specific message
104
+ - **clear_messages** - Delete all messages
105
+
106
+ See [CLAUDE_INTEGRATION.md](CLAUDE_INTEGRATION.md) for complete setup and usage guide.
107
+
54
108
  ## Documentation
55
109
 
56
110
  Detailed documentation is organized by topic:
@@ -75,6 +129,18 @@ Programmatic access to messages. Query, download, and manage messages via HTTP.
75
129
 
76
130
  SSL/TLS encryption, UTF-8 and international content, email authentication, and more.
77
131
 
132
+ ### [Claude Plugin Setup](reference/CLAUDE_PLUGIN_SETUP.md)
133
+
134
+ Use MailCatcher NG as a Claude Plugin for natural language interactions with caught emails. Perfect for testing email workflows with Claude.
135
+
136
+ ### [MCP Server Setup](reference/MCP_SETUP.md)
137
+
138
+ Configure MailCatcher NG as an MCP server for programmatic access via Claude Desktop and other MCP-compatible clients.
139
+
140
+ ### [Integration Architecture](reference/INTEGRATION_ARCHITECTURE.md)
141
+
142
+ Deep dive into how the Claude Plugin and MCP integrations work, including protocol specifications, tool definitions, and extension points.
143
+
78
144
  ### [Credits](reference/CREDITS.md)
79
145
 
80
146
  About MailCatcher NG and the original MailCatcher project.
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "mail_catcher/integrations/mcp_tools"
5
+
6
+ module MailCatcher
7
+ module Integrations
8
+ # MCP Server
9
+ # Implements the Model Context Protocol (MCP) over stdio
10
+ # Allows Claude and other MCP clients to interact with MailCatcher tools
11
+ class MCPServer
12
+ attr_accessor :running
13
+
14
+ def initialize(options = {})
15
+ @options = options
16
+ @running = false
17
+ @request_id_counter = 0
18
+ end
19
+
20
+ def self.start(options = {})
21
+ server = new(options)
22
+ server.run
23
+ server
24
+ end
25
+
26
+ # Main server loop - handles JSON-RPC messages from stdin
27
+ def run
28
+ @running = true
29
+ $stderr.puts "[MCP Server] Starting MailCatcher MCP Server"
30
+
31
+ # Output MCP initialization
32
+ send_response(
33
+ jsonrpc: "2.0",
34
+ id: 0,
35
+ result: {
36
+ protocolVersion: "2024-11-05",
37
+ capabilities: {
38
+ tools: {},
39
+ resources: {},
40
+ logging: {}
41
+ },
42
+ serverInfo: {
43
+ name: "mailcatcher-ng",
44
+ version: MailCatcher::VERSION
45
+ }
46
+ }
47
+ )
48
+
49
+ # Main loop - read and process requests
50
+ while @running && (line = $stdin.gets)
51
+ begin
52
+ request = JSON.parse(line)
53
+ handle_request(request)
54
+ rescue JSON::ParserError => e
55
+ $stderr.puts "[MCP Server] JSON parse error: #{e.message}"
56
+ send_error_response(nil, -32700, "Parse error")
57
+ rescue => e
58
+ $stderr.puts "[MCP Server] Error: #{e.message}"
59
+ $stderr.puts e.backtrace.first(5)
60
+ end
61
+ end
62
+
63
+ $stderr.puts "[MCP Server] MCP Server stopped"
64
+ @running = false
65
+ end
66
+
67
+ def stop
68
+ @running = false
69
+ end
70
+
71
+ private
72
+
73
+ def handle_request(request)
74
+ method = request["method"]
75
+ params = request["params"] || {}
76
+ request_id = request["id"]
77
+
78
+ $stderr.puts "[MCP Server] Received: #{method} (id: #{request_id})"
79
+
80
+ case method
81
+ when "initialize"
82
+ handle_initialize(request_id, params)
83
+ when "tools/list"
84
+ handle_tools_list(request_id)
85
+ when "tools/call"
86
+ handle_tools_call(request_id, params)
87
+ when "completion/complete"
88
+ handle_completion(request_id, params)
89
+ else
90
+ send_error_response(request_id, -32601, "Method not found: #{method}")
91
+ end
92
+ end
93
+
94
+ def handle_initialize(request_id, params)
95
+ send_response(
96
+ jsonrpc: "2.0",
97
+ id: request_id,
98
+ result: {
99
+ protocolVersion: "2024-11-05",
100
+ capabilities: {
101
+ tools: {},
102
+ resources: {},
103
+ logging: {}
104
+ },
105
+ serverInfo: {
106
+ name: "mailcatcher-ng",
107
+ version: MailCatcher::VERSION
108
+ }
109
+ }
110
+ )
111
+ end
112
+
113
+ def handle_tools_list(request_id)
114
+ tools = MCPTools.all_tools.map do |name, definition|
115
+ {
116
+ name: name.to_s,
117
+ description: definition[:description],
118
+ inputSchema: definition[:input_schema]
119
+ }
120
+ end
121
+
122
+ send_response(
123
+ jsonrpc: "2.0",
124
+ id: request_id,
125
+ result: {
126
+ tools: tools
127
+ }
128
+ )
129
+ end
130
+
131
+ def handle_tools_call(request_id, params)
132
+ tool_name = params["name"]
133
+ tool_input = params["arguments"] || {}
134
+
135
+ $stderr.puts "[MCP Server] Calling tool: #{tool_name} with input: #{tool_input.inspect}"
136
+
137
+ result = MCPTools.call_tool(tool_name, tool_input)
138
+
139
+ send_response(
140
+ jsonrpc: "2.0",
141
+ id: request_id,
142
+ result: {
143
+ content: [
144
+ {
145
+ type: "text",
146
+ text: JSON.pretty_generate(result)
147
+ }
148
+ ]
149
+ }
150
+ )
151
+ end
152
+
153
+ def handle_completion(request_id, params)
154
+ # Placeholder for completion support
155
+ send_response(
156
+ jsonrpc: "2.0",
157
+ id: request_id,
158
+ result: {
159
+ completion: {
160
+ values: [],
161
+ total: 0
162
+ }
163
+ }
164
+ )
165
+ end
166
+
167
+ def send_response(response)
168
+ output = JSON.generate(response)
169
+ puts output
170
+ $stderr.puts "[MCP Server] Sent: #{response[:id]}"
171
+ rescue => e
172
+ $stderr.puts "[MCP Server] Error sending response: #{e.message}"
173
+ end
174
+
175
+ def send_error_response(request_id, code, message)
176
+ send_response(
177
+ jsonrpc: "2.0",
178
+ id: request_id,
179
+ error: {
180
+ code: code,
181
+ message: message
182
+ }
183
+ )
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,370 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mail_catcher/mail"
4
+
5
+ module MailCatcher
6
+ module Integrations
7
+ # MCP Tool Registry
8
+ # Defines the set of tools available to Claude through MCP and Claude Plugins
9
+ # Each tool maps to existing Mail module methods
10
+ module MCPTools
11
+ extend self
12
+
13
+ # Tool definitions for MCP and Plugin
14
+ TOOLS = {
15
+ search_messages: {
16
+ description: "Search through caught emails with flexible filtering",
17
+ input_schema: {
18
+ type: "object",
19
+ properties: {
20
+ query: {
21
+ type: "string",
22
+ description: "Search term (searches subject, sender, recipients, body)"
23
+ },
24
+ limit: {
25
+ type: "integer",
26
+ description: "Maximum number of results to return",
27
+ default: 5
28
+ },
29
+ has_attachments: {
30
+ type: "boolean",
31
+ description: "Filter to only messages with attachments",
32
+ default: false
33
+ },
34
+ from_date: {
35
+ type: "string",
36
+ description: "ISO 8601 datetime to search from (e.g., '2024-01-12T00:00:00Z')",
37
+ default: nil
38
+ },
39
+ to_date: {
40
+ type: "string",
41
+ description: "ISO 8601 datetime to search until (e.g., '2024-01-12T23:59:59Z')",
42
+ default: nil
43
+ }
44
+ },
45
+ required: ["query"]
46
+ }
47
+ },
48
+
49
+ get_latest_message_for: {
50
+ description: "Get the latest email received by a specific recipient",
51
+ input_schema: {
52
+ type: "object",
53
+ properties: {
54
+ recipient: {
55
+ type: "string",
56
+ description: "Email address to match in recipients"
57
+ },
58
+ subject_contains: {
59
+ type: "string",
60
+ description: "Optional: Only return message if subject contains this text",
61
+ default: nil
62
+ }
63
+ },
64
+ required: ["recipient"]
65
+ }
66
+ },
67
+
68
+ extract_token_or_link: {
69
+ description: "Extract authentication tokens or links from a message",
70
+ input_schema: {
71
+ type: "object",
72
+ properties: {
73
+ message_id: {
74
+ type: "integer",
75
+ description: "ID of the message to extract from"
76
+ },
77
+ kind: {
78
+ type: "string",
79
+ enum: ["magic_link", "otp", "reset_token", "all"],
80
+ description: "Type of token/link to extract"
81
+ }
82
+ },
83
+ required: ["message_id", "kind"]
84
+ }
85
+ },
86
+
87
+ get_parsed_auth_info: {
88
+ description: "Get structured authentication information from a message",
89
+ input_schema: {
90
+ type: "object",
91
+ properties: {
92
+ message_id: {
93
+ type: "integer",
94
+ description: "ID of the message to parse"
95
+ }
96
+ },
97
+ required: ["message_id"]
98
+ }
99
+ },
100
+
101
+ get_message_preview_html: {
102
+ description: "Get HTML preview of a message (responsive for mobile if requested)",
103
+ input_schema: {
104
+ type: "object",
105
+ properties: {
106
+ message_id: {
107
+ type: "integer",
108
+ description: "ID of the message to preview"
109
+ },
110
+ mobile: {
111
+ type: "boolean",
112
+ description: "Return mobile-optimized preview",
113
+ default: false
114
+ }
115
+ },
116
+ required: ["message_id"]
117
+ }
118
+ },
119
+
120
+ delete_message: {
121
+ description: "Delete a specific message by ID",
122
+ input_schema: {
123
+ type: "object",
124
+ properties: {
125
+ message_id: {
126
+ type: "integer",
127
+ description: "ID of the message to delete"
128
+ }
129
+ },
130
+ required: ["message_id"]
131
+ }
132
+ },
133
+
134
+ clear_messages: {
135
+ description: "Delete all caught messages (destructive operation)",
136
+ input_schema: {
137
+ type: "object",
138
+ properties: {}
139
+ }
140
+ }
141
+ }.freeze
142
+
143
+ # Get tool definition by name
144
+ def tool(name)
145
+ TOOLS[name.to_sym]
146
+ end
147
+
148
+ # Get all tool names
149
+ def tool_names
150
+ TOOLS.keys.map(&:to_s)
151
+ end
152
+
153
+ # Get all tools
154
+ def all_tools
155
+ TOOLS
156
+ end
157
+
158
+ # Execute a tool with the given parameters
159
+ def call_tool(tool_name, input)
160
+ case tool_name.to_sym
161
+ when :search_messages
162
+ call_search_messages(input)
163
+ when :get_latest_message_for
164
+ call_get_latest_message_for(input)
165
+ when :extract_token_or_link
166
+ call_extract_token_or_link(input)
167
+ when :get_parsed_auth_info
168
+ call_get_parsed_auth_info(input)
169
+ when :get_message_preview_html
170
+ call_get_message_preview_html(input)
171
+ when :delete_message
172
+ call_delete_message(input)
173
+ when :clear_messages
174
+ call_clear_messages(input)
175
+ else
176
+ { error: "Unknown tool: #{tool_name}" }
177
+ end
178
+ rescue => e
179
+ {
180
+ error: "Tool execution failed: #{e.message}",
181
+ type: e.class.name,
182
+ backtrace: e.backtrace.first(3)
183
+ }
184
+ end
185
+
186
+ # Tool implementations
187
+
188
+ def call_search_messages(input)
189
+ query = input["query"] || input[:query]
190
+ limit = (input["limit"] || input[:limit] || 5).to_i
191
+ has_attachments = input["has_attachments"] || input[:has_attachments]
192
+ from_date = input["from_date"] || input[:from_date]
193
+ to_date = input["to_date"] || input[:to_date]
194
+
195
+ results = Mail.search_messages(
196
+ query: query,
197
+ has_attachments: has_attachments,
198
+ from_date: from_date,
199
+ to_date: to_date
200
+ )
201
+
202
+ # Limit results
203
+ results = results.slice(0, limit)
204
+
205
+ {
206
+ count: results.size,
207
+ messages: results.map { |msg| format_message_summary(msg) }
208
+ }
209
+ end
210
+
211
+ def call_get_latest_message_for(input)
212
+ recipient = input["recipient"] || input[:recipient]
213
+ subject_contains = input["subject_contains"] || input[:subject_contains]
214
+
215
+ # Search all messages to find matching recipient
216
+ all_messages = Mail.messages
217
+ matching = all_messages.select do |msg|
218
+ recipients = msg["recipients"]
219
+ recipients_array = recipients.is_a?(Array) ? recipients : [recipients]
220
+ recipients_array.any? { |r| r.to_s.include?(recipient) }
221
+ end
222
+
223
+ # Filter by subject if provided
224
+ matching = matching.select do |msg|
225
+ msg["subject"].to_s.include?(subject_contains)
226
+ end if subject_contains
227
+
228
+ # Get the latest
229
+ latest = matching.max_by { |msg| msg["created_at"] }
230
+
231
+ if latest
232
+ {
233
+ found: true,
234
+ message: format_message_detail(latest["id"])
235
+ }
236
+ else
237
+ {
238
+ found: false,
239
+ error: "No matching message found for recipient: #{recipient}"
240
+ }
241
+ end
242
+ end
243
+
244
+ def call_extract_token_or_link(input)
245
+ message_id = (input["message_id"] || input[:message_id]).to_i
246
+ kind = input["kind"] || input[:kind]
247
+
248
+ return { error: "Message not found" } unless Mail.message(message_id)
249
+
250
+ # Map kind parameter to Mail.extract_tokens type
251
+ type_map = {
252
+ "magic_link" => "link",
253
+ "otp" => "otp",
254
+ "reset_token" => "token",
255
+ "all" => "all"
256
+ }
257
+
258
+ type = type_map[kind]
259
+ return { error: "Invalid kind: #{kind}" } unless type
260
+
261
+ if kind == "all"
262
+ # Extract all types
263
+ {
264
+ magic_links: Mail.extract_tokens(message_id, type: 'link'),
265
+ otps: Mail.extract_tokens(message_id, type: 'otp'),
266
+ reset_tokens: Mail.extract_tokens(message_id, type: 'token')
267
+ }
268
+ else
269
+ {
270
+ extracted: Mail.extract_tokens(message_id, type: type)
271
+ }
272
+ end
273
+ end
274
+
275
+ def call_get_parsed_auth_info(input)
276
+ message_id = (input["message_id"] || input[:message_id]).to_i
277
+
278
+ return { error: "Message not found" } unless Mail.message(message_id)
279
+
280
+ parsed = Mail.parse_message_structured(message_id)
281
+ {
282
+ verification_url: parsed[:verification_url],
283
+ otp_code: parsed[:otp_code],
284
+ reset_token: parsed[:reset_token],
285
+ unsubscribe_link: parsed[:unsubscribe_link],
286
+ links_count: parsed[:all_links]&.count || 0,
287
+ links: parsed[:all_links]&.slice(0, 10) # Return first 10 links
288
+ }
289
+ end
290
+
291
+ def call_get_message_preview_html(input)
292
+ message_id = (input["message_id"] || input[:message_id]).to_i
293
+ mobile = input["mobile"] || input[:mobile] || false
294
+
295
+ html_part = Mail.message_part_html(message_id)
296
+ return { error: "No HTML content found in message" } unless html_part
297
+
298
+ body = html_part["body"].to_s
299
+
300
+ # For mobile, add viewport meta tag if not present
301
+ if mobile && !body.include?("viewport")
302
+ body = "<head><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"></head>\n" + body
303
+ end
304
+
305
+ # Truncate if very large (limit to ~200KB for performance)
306
+ if body.bytesize > 200_000
307
+ body = body[0, 200_000] + "\n<!-- Truncated for display -->"
308
+ end
309
+
310
+ {
311
+ message_id: message_id,
312
+ charset: html_part["charset"] || "utf-8",
313
+ mobile_optimized: mobile,
314
+ size_bytes: body.bytesize,
315
+ html: body
316
+ }
317
+ end
318
+
319
+ def call_delete_message(input)
320
+ message_id = (input["message_id"] || input[:message_id]).to_i
321
+
322
+ return { error: "Message not found" } unless Mail.message(message_id)
323
+
324
+ Mail.delete_message!(message_id)
325
+ {
326
+ deleted: true,
327
+ message_id: message_id
328
+ }
329
+ end
330
+
331
+ def call_clear_messages(input)
332
+ Mail.delete!
333
+ {
334
+ cleared: true,
335
+ message: "All messages have been deleted"
336
+ }
337
+ end
338
+
339
+ # Helper methods
340
+
341
+ def format_message_summary(message)
342
+ {
343
+ id: message["id"],
344
+ from: message["sender"],
345
+ to: message["recipients"],
346
+ subject: message["subject"],
347
+ size: message["size"],
348
+ created_at: message["created_at"]
349
+ }
350
+ end
351
+
352
+ def format_message_detail(message_id)
353
+ msg = Mail.message(message_id)
354
+ return nil unless msg
355
+
356
+ {
357
+ id: msg["id"],
358
+ from: msg["sender"],
359
+ to: msg["recipients"],
360
+ subject: msg["subject"],
361
+ size: msg["size"],
362
+ created_at: msg["created_at"],
363
+ has_html: Mail.message_has_html?(message_id),
364
+ has_plain: Mail.message_has_plain?(message_id),
365
+ attachments_count: Mail.message_attachments(message_id)&.count || 0
366
+ }
367
+ end
368
+ end
369
+ end
370
+ end