mailcatcher-ng 1.0.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,247 @@
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
+ @version = MailCatcher::VERSION
69
+ erb :index
70
+ end
71
+
72
+ get "/websocket-test" do
73
+ erb :websocket_test
74
+ end
75
+
76
+ get "/server-info" do
77
+ @version = MailCatcher::VERSION
78
+ @smtp_ip = MailCatcher.options[:smtp_ip]
79
+ @smtp_port = MailCatcher.options[:smtp_port]
80
+ @http_ip = MailCatcher.options[:http_ip]
81
+ @http_port = MailCatcher.options[:http_port]
82
+ @http_path = MailCatcher.options[:http_path]
83
+
84
+ require "socket"
85
+ if @http_ip == "127.0.0.1"
86
+ @hostname = "localhost"
87
+ @fqdn = "localhost"
88
+ else
89
+ begin
90
+ hostname = Socket.gethostname
91
+ fqdn = Socket.getfqdn(Socket.gethostname)
92
+ rescue
93
+ hostname = "unknown"
94
+ fqdn = "unknown"
95
+ end
96
+ @hostname = hostname
97
+ @fqdn = fqdn
98
+ end
99
+
100
+ erb :server_info
101
+ end
102
+
103
+ delete "/" do
104
+ if MailCatcher.quittable?
105
+ MailCatcher.quit!
106
+ status 204
107
+ else
108
+ status 403
109
+ end
110
+ end
111
+
112
+ get "/messages" do
113
+ if request.websocket?
114
+ bus_subscription = nil
115
+
116
+ ws = Faye::WebSocket.new(request.env)
117
+
118
+ ws.on(:open) do |_|
119
+ $stderr.puts "[WebSocket] Connection opened"
120
+ bus_subscription = MailCatcher::Bus.subscribe do |message|
121
+ begin
122
+ $stderr.puts "[WebSocket] Sending message: #{message.inspect}"
123
+ ws.send(JSON.generate(message))
124
+ rescue => exception
125
+ $stderr.puts "[WebSocket] Error sending message: #{exception.message}"
126
+ MailCatcher.log_exception("Error sending message through websocket", message, exception)
127
+ end
128
+ end
129
+ end
130
+
131
+ ws.on(:close) do |_|
132
+ $stderr.puts "[WebSocket] Connection closed"
133
+ MailCatcher::Bus.unsubscribe(bus_subscription) if bus_subscription
134
+ end
135
+
136
+ ws.on(:error) do |event|
137
+ $stderr.puts "[WebSocket] WebSocket error: #{event}"
138
+ end
139
+
140
+ ws.rack_response
141
+ else
142
+ content_type :json
143
+ JSON.generate(Mail.messages)
144
+ end
145
+ end
146
+
147
+ delete "/messages" do
148
+ Mail.delete!
149
+ status 204
150
+ end
151
+
152
+ get "/messages/:id.json" do
153
+ id = params[:id].to_i
154
+ if message = Mail.message(id)
155
+ content_type :json
156
+ JSON.generate(message.merge({
157
+ "formats" => [
158
+ "source",
159
+ ("html" if Mail.message_has_html? id),
160
+ ("plain" if Mail.message_has_plain? id)
161
+ ].compact,
162
+ "attachments" => Mail.message_attachments(id),
163
+ "bimi_location" => Mail.message_bimi_location(id),
164
+ "preview_text" => Mail.message_preview_text(id),
165
+ "authentication_results" => Mail.message_authentication_results(id),
166
+ "encryption_data" => Mail.message_encryption_data(id),
167
+ "from_header" => Mail.message_from(id),
168
+ "to_header" => Mail.message_to(id),
169
+ }))
170
+ else
171
+ not_found
172
+ end
173
+ end
174
+
175
+ get "/messages/:id.html" do
176
+ id = params[:id].to_i
177
+ if part = Mail.message_part_html(id)
178
+ content_type :html, :charset => (part["charset"] || "utf8")
179
+
180
+ body = part["body"]
181
+
182
+ # Rewrite body to link to embedded attachments served by cid
183
+ body = body.gsub /cid:([^'"> ]+)/, "#{id}/parts/\\1"
184
+
185
+ body
186
+ else
187
+ not_found
188
+ end
189
+ end
190
+
191
+ get "/messages/:id.plain" do
192
+ id = params[:id].to_i
193
+ if part = Mail.message_part_plain(id)
194
+ content_type part["type"], :charset => (part["charset"] || "utf8")
195
+ part["body"]
196
+ else
197
+ not_found
198
+ end
199
+ end
200
+
201
+ get "/messages/:id.source" do
202
+ id = params[:id].to_i
203
+ if message_source = Mail.message_source(id)
204
+ content_type "text/plain"
205
+ message_source
206
+ else
207
+ not_found
208
+ end
209
+ end
210
+
211
+ get "/messages/:id.eml" do
212
+ id = params[:id].to_i
213
+ if message_source = Mail.message_source(id)
214
+ content_type "message/rfc822"
215
+ message_source
216
+ else
217
+ not_found
218
+ end
219
+ end
220
+
221
+ get "/messages/:id/parts/:cid" do
222
+ id = params[:id].to_i
223
+ if part = Mail.message_part_cid(id, params[:cid])
224
+ content_type part["type"], :charset => (part["charset"] || "utf8")
225
+ attachment part["filename"] if part["is_attachment"] == 1
226
+ body part["body"].to_s
227
+ else
228
+ not_found
229
+ end
230
+ end
231
+
232
+ delete "/messages/:id" do
233
+ id = params[:id].to_i
234
+ if Mail.message(id)
235
+ Mail.delete_message!(id)
236
+ status 204
237
+ else
238
+ not_found
239
+ end
240
+ end
241
+
242
+ not_found do
243
+ erb :"404"
244
+ end
245
+ end
246
+ end
247
+ 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,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'open3'
5
+ require 'optparse'
6
+ require 'rbconfig'
7
+
8
+ require 'eventmachine'
9
+ require 'thin'
10
+
11
+ module EventMachine
12
+ # Monkey patch fix for 10deb4
13
+ # See https://github.com/eventmachine/eventmachine/issues/569
14
+ def self.reactor_running?
15
+ @reactor_running || false
16
+ end
17
+ end
18
+
19
+ require 'mail_catcher/version'
20
+
21
+ module MailCatcher
22
+ extend self
23
+ autoload :Bus, 'mail_catcher/bus'
24
+ autoload :Mail, 'mail_catcher/mail'
25
+ autoload :Smtp, 'mail_catcher/smtp'
26
+ autoload :Web, 'mail_catcher/web'
27
+
28
+ @logger = Logger.new($stdout)
29
+ @logger.level = Logger::INFO
30
+
31
+ def env
32
+ ENV.fetch('MAILCATCHER_ENV', 'production')
33
+ end
34
+
35
+ def development?
36
+ env == 'development'
37
+ end
38
+
39
+ def which?(command)
40
+ ENV['PATH'].split(File::PATH_SEPARATOR).any? do |directory|
41
+ File.executable?(File.join(directory, command.to_s))
42
+ end
43
+ end
44
+
45
+ def windows?
46
+ RbConfig::CONFIG['host_os'].match?(/mswin|mingw/)
47
+ end
48
+
49
+ def browsable?
50
+ windows? or which? 'open'
51
+ end
52
+
53
+ def browse(url)
54
+ if windows?
55
+ system 'start', '/b', url
56
+ elsif which? 'open'
57
+ system 'open', url
58
+ end
59
+ end
60
+
61
+ def log_exception(message, context, exception)
62
+ gems_paths = (Gem.path | [Gem.default_dir]).map { |path| Regexp.escape(path) }
63
+ gems_regexp = %r{(?:#{gems_paths.join('|')})/gems/([^/]+)-([\w.]+)/(.*)}
64
+ gems_replace = '\1 (\2) \3'
65
+
66
+ @logger.error("#{message}: #{context.inspect}")
67
+ @logger.error("Exception: #{exception}")
68
+
69
+ backtrace = exception.backtrace&.map { |line| line.sub(gems_regexp, gems_replace) }
70
+ backtrace&.each { |line| @logger.error(" #{line}") }
71
+
72
+ @logger.error('Please submit this as an issue at https://github.com/spaquet/mailcatcher')
73
+ end
74
+
75
+ @defaults = {
76
+ smtp_ip: '127.0.0.1',
77
+ smtp_port: '1025',
78
+ http_ip: '127.0.0.1',
79
+ http_port: '1080',
80
+ http_path: '/',
81
+ messages_limit: nil,
82
+ verbose: false,
83
+ daemon: !windows?,
84
+ browse: false,
85
+ quit: true
86
+ }
87
+
88
+ def options
89
+ @options
90
+ end
91
+
92
+ def quittable?
93
+ options[:quit]
94
+ end
95
+
96
+ def parse!(arguments = ARGV, defaults = @defaults)
97
+ @defaults.dup.tap do |options|
98
+ OptionParser.new do |parser|
99
+ parser.banner = 'Usage: mailcatcher [options]'
100
+ parser.version = VERSION
101
+ parser.separator ''
102
+ parser.separator "MailCatcher v#{VERSION}"
103
+ parser.separator ''
104
+
105
+ parser.on('--ip IP', 'Set the ip address of both servers') do |ip|
106
+ options[:smtp_ip] = options[:http_ip] = ip
107
+ end
108
+
109
+ parser.on('--smtp-ip IP', 'Set the ip address of the smtp server') do |ip|
110
+ options[:smtp_ip] = ip
111
+ end
112
+
113
+ parser.on('--smtp-port PORT', Integer, 'Set the port of the smtp server') do |port|
114
+ options[:smtp_port] = port
115
+ end
116
+
117
+ parser.on('--http-ip IP', 'Set the ip address of the http server') do |ip|
118
+ options[:http_ip] = ip
119
+ end
120
+
121
+ parser.on('--http-port PORT', Integer, 'Set the port address of the http server') do |port|
122
+ options[:http_port] = port
123
+ end
124
+
125
+ parser.on('--messages-limit COUNT', Integer,
126
+ 'Only keep up to COUNT most recent messages') do |count|
127
+ options[:messages_limit] = count
128
+ end
129
+
130
+ parser.on('--http-path PATH', String, 'Add a prefix to all HTTP paths') do |path|
131
+ clean_path = Rack::Utils.clean_path_info("/#{path}")
132
+
133
+ options[:http_path] = clean_path
134
+ end
135
+
136
+ parser.on('--no-quit', "Don't allow quitting the process") do
137
+ options[:quit] = false
138
+ end
139
+
140
+ unless windows?
141
+ parser.on('-f', '--foreground', 'Run in the foreground') do
142
+ options[:daemon] = false
143
+ end
144
+ end
145
+
146
+ if browsable?
147
+ parser.on('-b', '--browse', 'Open web browser') do
148
+ options[:browse] = true
149
+ end
150
+ end
151
+
152
+ parser.on('-v', '--verbose', 'Be more verbose') do
153
+ options[:verbose] = true
154
+ end
155
+
156
+ parser.on_tail('-h', '--help', 'Display this help information') do
157
+ puts parser
158
+ exit
159
+ end
160
+
161
+ parser.on_tail('--version', 'Display the current version') do
162
+ puts "MailCatcher v#{VERSION}"
163
+ exit
164
+ end
165
+ end.parse!
166
+ end
167
+ end
168
+
169
+ def run!(options = nil)
170
+ # If we are passed options, fill in the blanks
171
+ options &&= @defaults.merge options
172
+ # Otherwise, parse them from ARGV
173
+ options ||= parse!
174
+
175
+ # Stash them away for later
176
+ @options = options
177
+
178
+ # If we're running in the foreground sync the output.
179
+ $stdout.sync = $stderr.sync = true unless options[:daemon]
180
+
181
+ @logger.info("Starting MailCatcher v#{VERSION}")
182
+
183
+ Thin::Logging.debug = development?
184
+ Thin::Logging.silent = !development?
185
+ @logger.level = development? ? Logger::DEBUG : Logger::INFO
186
+
187
+ # One EventMachine loop...
188
+ EventMachine.run do
189
+ # Set up an SMTP server to run within EventMachine
190
+ rescue_port options[:smtp_port] do
191
+ EventMachine.start_server options[:smtp_ip], options[:smtp_port], Smtp
192
+ @logger.info("==> #{smtp_url}")
193
+ end
194
+
195
+ # Let Thin set itself up inside our EventMachine loop
196
+ # Faye connections are hijacked but continue to be supervised by thin
197
+ rescue_port options[:http_port] do
198
+ Thin::Server.start(options[:http_ip], options[:http_port], Web, signals: false)
199
+ @logger.info("==> #{http_url}")
200
+ end
201
+
202
+ # Make sure we quit nicely when asked
203
+ # We need to handle outside the trap context, hence the timer
204
+ trap('INT') { EM.add_timer(0) { quit! } }
205
+ trap('TERM') { EM.add_timer(0) { quit! } }
206
+ trap('QUIT') { EM.add_timer(0) { quit! } } unless windows?
207
+
208
+ # Open the web browser before detaching console
209
+ if options[:browse]
210
+ EventMachine.next_tick do
211
+ browse http_url
212
+ end
213
+ end
214
+
215
+ # Daemonize, if we should, but only after the servers have started.
216
+ if options[:daemon]
217
+ EventMachine.next_tick do
218
+ if quittable?
219
+ @logger.info('MailCatcher runs as a daemon by default. Go to the web interface to quit.')
220
+ else
221
+ @logger.info('MailCatcher is now running as a daemon that cannot be quit.')
222
+ end
223
+ Process.daemon
224
+ end
225
+ end
226
+ end
227
+ end
228
+
229
+ def quit!
230
+ MailCatcher::Bus.push(type: 'quit')
231
+
232
+ EventMachine.next_tick { EventMachine.stop_event_loop }
233
+ end
234
+
235
+ protected
236
+
237
+ def smtp_url
238
+ "smtp://#{@options[:smtp_ip]}:#{@options[:smtp_port]}"
239
+ end
240
+
241
+ def http_url
242
+ "http://#{@options[:http_ip]}:#{@options[:http_port]}#{@options[:http_path]}".chomp('/')
243
+ end
244
+
245
+ def rescue_port(port)
246
+ yield
247
+
248
+ # XXX: EventMachine only spits out RuntimeError with a string description
249
+ rescue RuntimeError
250
+ raise unless $!.to_s =~ /\bno acceptor\b/
251
+
252
+ @logger.error("Something's using port #{port}. Are you already running MailCatcher?")
253
+ @logger.error("==> #{smtp_url}")
254
+ @logger.error("==> #{http_url}")
255
+ exit(-1)
256
+ end
257
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mail_catcher'
4
+
5
+ Mailcatcher = MailCatcher