mailcatcher2 0.10.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mail_catcher"
4
+
5
+ Mailcatcher = MailCatcher
Binary file
data/views/404.erb ADDED
@@ -0,0 +1,6 @@
1
+ <html>
2
+ <body>
3
+ <h1>No Dice</h1>
4
+ <p>The message you were looking for does not exist, or doesn't have content of this type.</p>
5
+ </body>
6
+ </html>
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>