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.
@@ -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>