mailcatcher2 0.10.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/LICENSE +20 -0
- data/README.md +176 -0
- data/bin/catchmail +72 -0
- data/bin/mailcatcher +6 -0
- data/lib/mail_catcher/bus.rb +7 -0
- data/lib/mail_catcher/mail.rb +183 -0
- data/lib/mail_catcher/smtp.rb +68 -0
- data/lib/mail_catcher/version.rb +5 -0
- data/lib/mail_catcher/web/application.rb +200 -0
- data/lib/mail_catcher/web.rb +29 -0
- data/lib/mail_catcher.rb +253 -0
- data/lib/mailcatcher.rb +5 -0
- data/public/favicon.ico +0 -0
- data/views/404.erb +6 -0
- data/views/index.erb +70 -0
- metadata +360 -0
@@ -0,0 +1,200 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "pathname"
|
4
|
+
require "net/http"
|
5
|
+
require "uri"
|
6
|
+
|
7
|
+
require "faye/websocket"
|
8
|
+
require "sinatra"
|
9
|
+
|
10
|
+
require "mail_catcher/bus"
|
11
|
+
require "mail_catcher/mail"
|
12
|
+
|
13
|
+
Faye::WebSocket.load_adapter("thin")
|
14
|
+
|
15
|
+
# Faye's adapter isn't smart enough to close websockets when thin is stopped,
|
16
|
+
# so we teach it to do so.
|
17
|
+
class Thin::Backends::Base
|
18
|
+
alias :thin_stop :stop
|
19
|
+
|
20
|
+
def stop
|
21
|
+
thin_stop
|
22
|
+
@connections.each_value do |connection|
|
23
|
+
if connection.socket_stream
|
24
|
+
connection.socket_stream.close_connection_after_writing
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
class Sinatra::Request
|
31
|
+
include Faye::WebSocket::Adapter
|
32
|
+
end
|
33
|
+
|
34
|
+
module MailCatcher
|
35
|
+
module Web
|
36
|
+
class Application < Sinatra::Base
|
37
|
+
set :environment, MailCatcher.env
|
38
|
+
set :prefix, MailCatcher.options[:http_path]
|
39
|
+
set :asset_prefix, File.join(prefix, "assets")
|
40
|
+
set :root, File.expand_path("#{__FILE__}/../../../..")
|
41
|
+
|
42
|
+
if development?
|
43
|
+
require "sprockets-helpers"
|
44
|
+
|
45
|
+
configure do
|
46
|
+
require "mail_catcher/web/assets"
|
47
|
+
Sprockets::Helpers.configure do |config|
|
48
|
+
config.environment = Assets
|
49
|
+
config.prefix = settings.asset_prefix
|
50
|
+
config.digest = false
|
51
|
+
config.public_path = public_folder
|
52
|
+
config.debug = true
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
helpers do
|
57
|
+
include Sprockets::Helpers
|
58
|
+
end
|
59
|
+
else
|
60
|
+
helpers do
|
61
|
+
def asset_path(filename)
|
62
|
+
File.join(settings.asset_prefix, filename)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
get "/" do
|
68
|
+
erb :index
|
69
|
+
end
|
70
|
+
|
71
|
+
delete "/" do
|
72
|
+
if MailCatcher.quittable?
|
73
|
+
MailCatcher.quit!
|
74
|
+
status 204
|
75
|
+
else
|
76
|
+
status 403
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
get "/messages" do
|
81
|
+
if request.websocket?
|
82
|
+
bus_subscription = nil
|
83
|
+
|
84
|
+
ws = Faye::WebSocket.new(request.env)
|
85
|
+
ws.on(:open) do |_|
|
86
|
+
bus_subscription = MailCatcher::Bus.subscribe do |message|
|
87
|
+
begin
|
88
|
+
ws.send(JSON.generate(message))
|
89
|
+
rescue => exception
|
90
|
+
MailCatcher.log_exception("Error sending message through websocket", message, exception)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
ws.on(:close) do |_|
|
96
|
+
MailCatcher::Bus.unsubscribe(bus_subscription) if bus_subscription
|
97
|
+
end
|
98
|
+
|
99
|
+
ws.rack_response
|
100
|
+
else
|
101
|
+
content_type :json
|
102
|
+
JSON.generate(Mail.messages)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
delete "/messages" do
|
107
|
+
Mail.delete!
|
108
|
+
status 204
|
109
|
+
end
|
110
|
+
|
111
|
+
get "/messages/:id.json" do
|
112
|
+
id = params[:id].to_i
|
113
|
+
if message = Mail.message(id)
|
114
|
+
content_type :json
|
115
|
+
JSON.generate(message.merge({
|
116
|
+
"formats" => [
|
117
|
+
"source",
|
118
|
+
("html" if Mail.message_has_html? id),
|
119
|
+
("plain" if Mail.message_has_plain? id)
|
120
|
+
].compact,
|
121
|
+
"attachments" => Mail.message_attachments(id),
|
122
|
+
}))
|
123
|
+
else
|
124
|
+
not_found
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
get "/messages/:id.html" do
|
129
|
+
id = params[:id].to_i
|
130
|
+
if part = Mail.message_part_html(id)
|
131
|
+
content_type :html, :charset => (part["charset"] || "utf8")
|
132
|
+
|
133
|
+
body = part["body"]
|
134
|
+
|
135
|
+
# Rewrite body to link to embedded attachments served by cid
|
136
|
+
body.gsub! /cid:([^'"> ]+)/, "#{id}/parts/\\1"
|
137
|
+
|
138
|
+
body
|
139
|
+
else
|
140
|
+
not_found
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
get "/messages/:id.plain" do
|
145
|
+
id = params[:id].to_i
|
146
|
+
if part = Mail.message_part_plain(id)
|
147
|
+
content_type part["type"], :charset => (part["charset"] || "utf8")
|
148
|
+
part["body"]
|
149
|
+
else
|
150
|
+
not_found
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
get "/messages/:id.source" do
|
155
|
+
id = params[:id].to_i
|
156
|
+
if message_source = Mail.message_source(id)
|
157
|
+
content_type "text/plain"
|
158
|
+
message_source
|
159
|
+
else
|
160
|
+
not_found
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
get "/messages/:id.eml" do
|
165
|
+
id = params[:id].to_i
|
166
|
+
if message_source = Mail.message_source(id)
|
167
|
+
content_type "message/rfc822"
|
168
|
+
message_source
|
169
|
+
else
|
170
|
+
not_found
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
get "/messages/:id/parts/:cid" do
|
175
|
+
id = params[:id].to_i
|
176
|
+
if part = Mail.message_part_cid(id, params[:cid])
|
177
|
+
content_type part["type"], :charset => (part["charset"] || "utf8")
|
178
|
+
attachment part["filename"] if part["is_attachment"] == 1
|
179
|
+
body part["body"].to_s
|
180
|
+
else
|
181
|
+
not_found
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
delete "/messages/:id" do
|
186
|
+
id = params[:id].to_i
|
187
|
+
if Mail.message(id)
|
188
|
+
Mail.delete_message!(id)
|
189
|
+
status 204
|
190
|
+
else
|
191
|
+
not_found
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
not_found do
|
196
|
+
erb :"404"
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rack/builder"
|
4
|
+
|
5
|
+
require "mail_catcher/web/application"
|
6
|
+
|
7
|
+
module MailCatcher
|
8
|
+
module Web extend self
|
9
|
+
def app
|
10
|
+
@@app ||= Rack::Builder.new do
|
11
|
+
map(MailCatcher.options[:http_path]) do
|
12
|
+
if MailCatcher.development?
|
13
|
+
require "mail_catcher/web/assets"
|
14
|
+
map("/assets") { run Assets }
|
15
|
+
end
|
16
|
+
|
17
|
+
run Application
|
18
|
+
end
|
19
|
+
|
20
|
+
# This should only affect when http_path is anything but "/" above
|
21
|
+
run lambda { |env| [302, {"Location" => MailCatcher.options[:http_path]}, []] }
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def call(env)
|
26
|
+
app.call(env)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
data/lib/mail_catcher.rb
ADDED
@@ -0,0 +1,253 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "open3"
|
4
|
+
require "optparse"
|
5
|
+
require "rbconfig"
|
6
|
+
|
7
|
+
require "eventmachine"
|
8
|
+
require "thin"
|
9
|
+
|
10
|
+
module EventMachine
|
11
|
+
# Monkey patch fix for 10deb4
|
12
|
+
# See https://github.com/eventmachine/eventmachine/issues/569
|
13
|
+
def self.reactor_running?
|
14
|
+
(@reactor_running || false)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
require "mail_catcher/version"
|
19
|
+
|
20
|
+
module MailCatcher extend self
|
21
|
+
autoload :Bus, "mail_catcher/bus"
|
22
|
+
autoload :Mail, "mail_catcher/mail"
|
23
|
+
autoload :Smtp, "mail_catcher/smtp"
|
24
|
+
autoload :Web, "mail_catcher/web"
|
25
|
+
|
26
|
+
def env
|
27
|
+
ENV.fetch("MAILCATCHER_ENV", "production")
|
28
|
+
end
|
29
|
+
|
30
|
+
def development?
|
31
|
+
env == "development"
|
32
|
+
end
|
33
|
+
|
34
|
+
def which?(command)
|
35
|
+
ENV["PATH"].split(File::PATH_SEPARATOR).any? do |directory|
|
36
|
+
File.executable?(File.join(directory, command.to_s))
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def windows?
|
41
|
+
RbConfig::CONFIG["host_os"].match?(/mswin|mingw/)
|
42
|
+
end
|
43
|
+
|
44
|
+
def browsable?
|
45
|
+
windows? or which? "open"
|
46
|
+
end
|
47
|
+
|
48
|
+
def browse url
|
49
|
+
if windows?
|
50
|
+
system "start", "/b", url
|
51
|
+
elsif which? "open"
|
52
|
+
system "open", url
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def log_exception(message, context, exception)
|
57
|
+
gems_paths = (Gem.path | [Gem.default_dir]).map { |path| Regexp.escape(path) }
|
58
|
+
gems_regexp = %r{(?:#{gems_paths.join("|")})/gems/([^/]+)-([\w.]+)/(.*)}
|
59
|
+
gems_replace = '\1 (\2) \3'
|
60
|
+
|
61
|
+
puts "*** #{message}: #{context.inspect}"
|
62
|
+
puts " Exception: #{exception}"
|
63
|
+
puts " Backtrace:", *exception.backtrace.map { |line| " #{line.sub(gems_regexp, gems_replace)}" }
|
64
|
+
puts " Please submit this as an issue at https://github.com/sj26/mailcatcher/issues"
|
65
|
+
end
|
66
|
+
|
67
|
+
@@defaults = {
|
68
|
+
:smtp_ip => "127.0.0.1",
|
69
|
+
:smtp_port => "1025",
|
70
|
+
:http_ip => "127.0.0.1",
|
71
|
+
:http_port => "1080",
|
72
|
+
:http_path => "/",
|
73
|
+
:messages_limit => nil,
|
74
|
+
:verbose => false,
|
75
|
+
:daemon => !windows?,
|
76
|
+
:browse => false,
|
77
|
+
:quit => true,
|
78
|
+
}
|
79
|
+
|
80
|
+
def options
|
81
|
+
@@options
|
82
|
+
end
|
83
|
+
|
84
|
+
def quittable?
|
85
|
+
options[:quit]
|
86
|
+
end
|
87
|
+
|
88
|
+
def parse! arguments=ARGV, defaults=@defaults
|
89
|
+
@@defaults.dup.tap do |options|
|
90
|
+
OptionParser.new do |parser|
|
91
|
+
parser.banner = "Usage: mailcatcher [options]"
|
92
|
+
parser.version = VERSION
|
93
|
+
parser.separator ""
|
94
|
+
parser.separator "MailCatcher v#{VERSION}"
|
95
|
+
parser.separator ""
|
96
|
+
|
97
|
+
parser.on("--ip IP", "Set the ip address of both servers") do |ip|
|
98
|
+
options[:smtp_ip] = options[:http_ip] = ip
|
99
|
+
end
|
100
|
+
|
101
|
+
parser.on("--smtp-ip IP", "Set the ip address of the smtp server") do |ip|
|
102
|
+
options[:smtp_ip] = ip
|
103
|
+
end
|
104
|
+
|
105
|
+
parser.on("--smtp-port PORT", Integer, "Set the port of the smtp server") do |port|
|
106
|
+
options[:smtp_port] = port
|
107
|
+
end
|
108
|
+
|
109
|
+
parser.on("--http-ip IP", "Set the ip address of the http server") do |ip|
|
110
|
+
options[:http_ip] = ip
|
111
|
+
end
|
112
|
+
|
113
|
+
parser.on("--http-port PORT", Integer, "Set the port address of the http server") do |port|
|
114
|
+
options[:http_port] = port
|
115
|
+
end
|
116
|
+
|
117
|
+
parser.on("--messages-limit COUNT", Integer, "Only keep up to COUNT most recent messages") do |count|
|
118
|
+
options[:messages_limit] = count
|
119
|
+
end
|
120
|
+
|
121
|
+
parser.on("--http-path PATH", String, "Add a prefix to all HTTP paths") do |path|
|
122
|
+
clean_path = Rack::Utils.clean_path_info("/#{path}")
|
123
|
+
|
124
|
+
options[:http_path] = clean_path
|
125
|
+
end
|
126
|
+
|
127
|
+
parser.on("--no-quit", "Don't allow quitting the process") do
|
128
|
+
options[:quit] = false
|
129
|
+
end
|
130
|
+
|
131
|
+
unless windows?
|
132
|
+
parser.on("-f", "--foreground", "Run in the foreground") do
|
133
|
+
options[:daemon] = false
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
if browsable?
|
138
|
+
parser.on("-b", "--browse", "Open web browser") do
|
139
|
+
options[:browse] = true
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
parser.on("-v", "--verbose", "Be more verbose") do
|
144
|
+
options[:verbose] = true
|
145
|
+
end
|
146
|
+
|
147
|
+
parser.on_tail("-h", "--help", "Display this help information") do
|
148
|
+
puts parser
|
149
|
+
exit
|
150
|
+
end
|
151
|
+
|
152
|
+
parser.on_tail("--version", "Display the current version") do
|
153
|
+
puts "MailCatcher v#{VERSION}"
|
154
|
+
exit
|
155
|
+
end
|
156
|
+
end.parse!
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
def run! options=nil
|
161
|
+
# If we are passed options, fill in the blanks
|
162
|
+
options &&= @@defaults.merge options
|
163
|
+
# Otherwise, parse them from ARGV
|
164
|
+
options ||= parse!
|
165
|
+
|
166
|
+
# Stash them away for later
|
167
|
+
@@options = options
|
168
|
+
|
169
|
+
# If we're running in the foreground sync the output.
|
170
|
+
unless options[:daemon]
|
171
|
+
$stdout.sync = $stderr.sync = true
|
172
|
+
end
|
173
|
+
|
174
|
+
puts "Starting MailCatcher v#{VERSION}"
|
175
|
+
|
176
|
+
Thin::Logging.debug = development?
|
177
|
+
Thin::Logging.silent = !development?
|
178
|
+
|
179
|
+
# One EventMachine loop...
|
180
|
+
EventMachine.run do
|
181
|
+
# Set up an SMTP server to run within EventMachine
|
182
|
+
rescue_port options[:smtp_port] do
|
183
|
+
EventMachine.start_server options[:smtp_ip], options[:smtp_port], Smtp
|
184
|
+
puts "==> #{smtp_url}"
|
185
|
+
end
|
186
|
+
|
187
|
+
# Let Thin set itself up inside our EventMachine loop
|
188
|
+
# Faye connections are hijacked but continue to be supervised by thin
|
189
|
+
rescue_port options[:http_port] do
|
190
|
+
Thin::Server.start(options[:http_ip], options[:http_port], Web, signals: false)
|
191
|
+
puts "==> #{http_url}"
|
192
|
+
end
|
193
|
+
|
194
|
+
# Make sure we quit nicely when asked
|
195
|
+
# We need to handle outside the trap context, hence the timer
|
196
|
+
trap("INT") { EM.add_timer(0) { quit! } }
|
197
|
+
trap("TERM") { EM.add_timer(0) { quit! } }
|
198
|
+
trap("QUIT") { EM.add_timer(0) { quit! } } unless windows?
|
199
|
+
|
200
|
+
# Open the web browser before detaching console
|
201
|
+
if options[:browse]
|
202
|
+
EventMachine.next_tick do
|
203
|
+
browse http_url
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
# Daemonize, if we should, but only after the servers have started.
|
208
|
+
if options[:daemon]
|
209
|
+
EventMachine.next_tick do
|
210
|
+
if quittable?
|
211
|
+
puts "*** MailCatcher runs as a daemon by default. Go to the web interface to quit."
|
212
|
+
else
|
213
|
+
puts "*** MailCatcher is now running as a daemon that cannot be quit."
|
214
|
+
end
|
215
|
+
Process.daemon
|
216
|
+
end
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
def quit!
|
222
|
+
MailCatcher::Bus.push(type: "quit")
|
223
|
+
|
224
|
+
EventMachine.next_tick { EventMachine.stop_event_loop }
|
225
|
+
end
|
226
|
+
|
227
|
+
protected
|
228
|
+
|
229
|
+
def smtp_url
|
230
|
+
"smtp://#{@@options[:smtp_ip]}:#{@@options[:smtp_port]}"
|
231
|
+
end
|
232
|
+
|
233
|
+
def http_url
|
234
|
+
"http://#{@@options[:http_ip]}:#{@@options[:http_port]}#{@@options[:http_path]}".chomp("/")
|
235
|
+
end
|
236
|
+
|
237
|
+
def rescue_port port
|
238
|
+
begin
|
239
|
+
yield
|
240
|
+
|
241
|
+
# XXX: EventMachine only spits out RuntimeError with a string description
|
242
|
+
rescue RuntimeError
|
243
|
+
if $!.to_s =~ /\bno acceptor\b/
|
244
|
+
puts "~~> ERROR: Something's using port #{port}. Are you already running MailCatcher?"
|
245
|
+
puts "==> #{smtp_url}"
|
246
|
+
puts "==> #{http_url}"
|
247
|
+
exit -1
|
248
|
+
else
|
249
|
+
raise
|
250
|
+
end
|
251
|
+
end
|
252
|
+
end
|
253
|
+
end
|
data/lib/mailcatcher.rb
ADDED
data/public/favicon.ico
ADDED
Binary file
|
data/views/404.erb
ADDED
data/views/index.erb
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html class="mailcatcher">
|
3
|
+
<head>
|
4
|
+
<title>MailCatcher</title>
|
5
|
+
<base href="<%= settings.prefix.chomp("/") %>/">
|
6
|
+
<link href="favicon.ico" rel="icon">
|
7
|
+
<script src="<%= asset_path("mailcatcher.js") %>"></script>
|
8
|
+
<link rel="stylesheet" href="<%= asset_path("mailcatcher.css") %>">
|
9
|
+
</head>
|
10
|
+
<body>
|
11
|
+
<noscript>
|
12
|
+
<div id="noscript-overlay">
|
13
|
+
<div id="noscript">
|
14
|
+
MailCatcher requires JavaScript to be enabled.
|
15
|
+
</div>
|
16
|
+
</div>
|
17
|
+
</noscript>
|
18
|
+
<header>
|
19
|
+
<h1><a href="https://mailcatcher.me" target="_blank">MailCatcher</a></h1>
|
20
|
+
<nav class="app">
|
21
|
+
<ul>
|
22
|
+
<li class="search"><input type="search" name="search" placeholder="Search messages..." incremental="true" /></li>
|
23
|
+
<li class="clear"><a href="#" title="Clear all messages">Clear</a></li>
|
24
|
+
<% if MailCatcher.quittable? %>
|
25
|
+
<li class="quit"><a href="#" title="Quit MailCatcher">Quit</a></li>
|
26
|
+
<% end %>
|
27
|
+
</ul>
|
28
|
+
</nav>
|
29
|
+
</header>
|
30
|
+
<nav id="messages">
|
31
|
+
<table>
|
32
|
+
<thead>
|
33
|
+
<tr>
|
34
|
+
<th>From</th>
|
35
|
+
<th>To</th>
|
36
|
+
<th>Subject</th>
|
37
|
+
<th>Received</th>
|
38
|
+
</tr>
|
39
|
+
</thead>
|
40
|
+
<tbody></tbody>
|
41
|
+
</table>
|
42
|
+
</nav>
|
43
|
+
<div id="resizer"><div class="ruler"></div></div>
|
44
|
+
<article id="message">
|
45
|
+
<header>
|
46
|
+
<dl class="metadata">
|
47
|
+
<dt class="created_at">Received</dt>
|
48
|
+
<dd class="created_at"></dd>
|
49
|
+
<dt class="from">From</dt>
|
50
|
+
<dd class="from"></dd>
|
51
|
+
<dt class="to">To</dt>
|
52
|
+
<dd class="to"></dd>
|
53
|
+
<dt class="subject">Subject</dt>
|
54
|
+
<dd class="subject"></dd>
|
55
|
+
<dt class="attachments">Attachments</dt>
|
56
|
+
<dd class="attachments"></dd>
|
57
|
+
</dl>
|
58
|
+
<nav class="views">
|
59
|
+
<ul>
|
60
|
+
<li class="format tab html selected" data-message-format="html"><a href="#">HTML</a></li>
|
61
|
+
<li class="format tab plain" data-message-format="plain"><a href="#">Plain Text</a></li>
|
62
|
+
<li class="format tab source" data-message-format="source"><a href="#">Source</a></li>
|
63
|
+
<li class="action download" data-message-format="html"><a href="#" class="button"><span>Download</span></a></li>
|
64
|
+
</ul>
|
65
|
+
</nav>
|
66
|
+
</header>
|
67
|
+
<iframe class="body"></iframe>
|
68
|
+
</article>
|
69
|
+
</body>
|
70
|
+
</html>
|