mailcatcher-ng 1.4.6 → 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 +4 -4
- data/README.md +66 -0
- data/lib/mail_catcher/integrations/mcp_server.rb +187 -0
- data/lib/mail_catcher/integrations/mcp_tools.rb +370 -0
- data/lib/mail_catcher/integrations.rb +63 -0
- data/lib/mail_catcher/mail.rb +328 -0
- data/lib/mail_catcher/version.rb +1 -1
- data/lib/mail_catcher/web/application.rb +442 -0
- data/lib/mail_catcher.rb +42 -1
- data/public/assets/mailcatcher.css +154 -0
- data/public/assets/mailcatcher.js +176 -0
- data/views/index.erb +29 -0
- metadata +18 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 69f0a5c91a168bb7b409d219e6419a404c80a1623184b3899e58527562bd9a43
|
|
4
|
+
data.tar.gz: 0b849b98f7b13db2022a49edf28ca88015521d4426db272b3fcc3355f95bc587
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|