mailcatcher 0.5.12 → 0.8.1

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,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ require "sinatra"
8
+ require "skinny"
9
+
10
+ require "mail_catcher/bus"
11
+ require "mail_catcher/mail"
12
+
13
+ class Sinatra::Request
14
+ include Skinny::Helpers
15
+ end
16
+
17
+ module MailCatcher
18
+ module Web
19
+ class Application < Sinatra::Base
20
+ set :environment, MailCatcher.env
21
+ set :prefix, MailCatcher.options[:http_path]
22
+ set :asset_prefix, File.join(prefix, "assets")
23
+ set :root, File.expand_path("#{__FILE__}/../../../..")
24
+
25
+ if development?
26
+ require "sprockets-helpers"
27
+
28
+ configure do
29
+ require "mail_catcher/web/assets"
30
+ Sprockets::Helpers.configure do |config|
31
+ config.environment = Assets
32
+ config.prefix = settings.asset_prefix
33
+ config.digest = false
34
+ config.public_path = public_folder
35
+ config.debug = true
36
+ end
37
+ end
38
+
39
+ helpers do
40
+ include Sprockets::Helpers
41
+ end
42
+ else
43
+ helpers do
44
+ def asset_path(filename)
45
+ File.join(settings.asset_prefix, filename)
46
+ end
47
+ end
48
+ end
49
+
50
+ get "/" do
51
+ erb :index
52
+ end
53
+
54
+ delete "/" do
55
+ if MailCatcher.quittable?
56
+ MailCatcher.quit!
57
+ status 204
58
+ else
59
+ status 403
60
+ end
61
+ end
62
+
63
+ get "/messages" do
64
+ if request.websocket?
65
+ request.websocket!(
66
+ :on_start => proc do |websocket|
67
+ bus_subscription = MailCatcher::Bus.subscribe do |message|
68
+ begin
69
+ websocket.send_message(JSON.generate(message))
70
+ rescue => exception
71
+ MailCatcher.log_exception("Error sending message through websocket", message, exception)
72
+ end
73
+ end
74
+
75
+ websocket.on_close do |*|
76
+ MailCatcher::Bus.unsubscribe bus_subscription
77
+ end
78
+ end)
79
+ else
80
+ content_type :json
81
+ JSON.generate(Mail.messages)
82
+ end
83
+ end
84
+
85
+ delete "/messages" do
86
+ Mail.delete!
87
+ status 204
88
+ end
89
+
90
+ get "/messages/:id.json" do
91
+ id = params[:id].to_i
92
+ if message = Mail.message(id)
93
+ content_type :json
94
+ JSON.generate(message.merge({
95
+ "formats" => [
96
+ "source",
97
+ ("html" if Mail.message_has_html? id),
98
+ ("plain" if Mail.message_has_plain? id)
99
+ ].compact,
100
+ "attachments" => Mail.message_attachments(id),
101
+ }))
102
+ else
103
+ not_found
104
+ end
105
+ end
106
+
107
+ get "/messages/:id.html" do
108
+ id = params[:id].to_i
109
+ if part = Mail.message_part_html(id)
110
+ content_type :html, :charset => (part["charset"] || "utf8")
111
+
112
+ body = part["body"]
113
+
114
+ # Rewrite body to link to embedded attachments served by cid
115
+ body.gsub! /cid:([^'"> ]+)/, "#{id}/parts/\\1"
116
+
117
+ body
118
+ else
119
+ not_found
120
+ end
121
+ end
122
+
123
+ get "/messages/:id.plain" do
124
+ id = params[:id].to_i
125
+ if part = Mail.message_part_plain(id)
126
+ content_type part["type"], :charset => (part["charset"] || "utf8")
127
+ part["body"]
128
+ else
129
+ not_found
130
+ end
131
+ end
132
+
133
+ get "/messages/:id.source" do
134
+ id = params[:id].to_i
135
+ if message_source = Mail.message_source(id)
136
+ content_type "text/plain"
137
+ message_source
138
+ else
139
+ not_found
140
+ end
141
+ end
142
+
143
+ get "/messages/:id.eml" do
144
+ id = params[:id].to_i
145
+ if message_source = Mail.message_source(id)
146
+ content_type "message/rfc822"
147
+ message_source
148
+ else
149
+ not_found
150
+ end
151
+ end
152
+
153
+ get "/messages/:id/parts/:cid" do
154
+ id = params[:id].to_i
155
+ if part = Mail.message_part_cid(id, params[:cid])
156
+ content_type part["type"], :charset => (part["charset"] || "utf8")
157
+ attachment part["filename"] if part["is_attachment"] == 1
158
+ body part["body"].to_s
159
+ else
160
+ not_found
161
+ end
162
+ end
163
+
164
+ delete "/messages/:id" do
165
+ id = params[:id].to_i
166
+ if Mail.message(id)
167
+ Mail.delete_message!(id)
168
+ status 204
169
+ else
170
+ not_found
171
+ end
172
+ end
173
+
174
+ not_found do
175
+ erb :"404"
176
+ end
177
+ end
178
+ end
179
+ end
@@ -1,148 +1,29 @@
1
- require 'sinatra'
2
- require 'pathname'
3
- require 'net/http'
4
- require 'uri'
1
+ # frozen_string_literal: true
5
2
 
6
- require 'skinny'
3
+ require "rack/builder"
7
4
 
8
- class Sinatra::Request
9
- include Skinny::Helpers
10
- end
11
-
12
- class MailCatcher::Web < Sinatra::Base
13
- set :root, File.expand_path("#{__FILE__}/../../..")
14
- set :haml, :format => :html5
5
+ require "mail_catcher/web/application"
15
6
 
16
- get '/' do
17
- haml :index
18
- end
19
-
20
- delete '/' do
21
- MailCatcher.quit!
22
- status 204
23
- end
24
-
25
- get '/messages' do
26
- if request.websocket?
27
- request.websocket!(
28
- :on_start => proc do |websocket|
29
- subscription = MailCatcher::Events::MessageAdded.subscribe { |message| websocket.send_message message.to_json }
30
- websocket.on_close do |websocket|
31
- MailCatcher::Events::MessageAdded.unsubscribe subscription
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 }
32
15
  end
33
- end)
34
- else
35
- MailCatcher::Mail.messages.to_json
36
- end
37
- end
38
-
39
- delete '/messages' do
40
- MailCatcher::Mail.delete!
41
- status 204
42
- end
43
-
44
- get '/messages/:id.json' do
45
- id = params[:id].to_i
46
- if message = MailCatcher::Mail.message(id)
47
- message.merge({
48
- "formats" => [
49
- "source",
50
- ("html" if MailCatcher::Mail.message_has_html? id),
51
- ("plain" if MailCatcher::Mail.message_has_plain? id),
52
- ].compact,
53
- "attachments" => MailCatcher::Mail.message_attachments(id).map do |attachment|
54
- attachment.merge({"href" => "/messages/#{escape(id)}/parts/#{escape(attachment['cid'])}"})
55
- end,
56
- }).to_json
57
- else
58
- not_found
59
- end
60
- end
61
-
62
- get '/messages/:id.html' do
63
- id = params[:id].to_i
64
- if part = MailCatcher::Mail.message_part_html(id)
65
- content_type part["type"], :charset => (part["charset"] || "utf8")
66
-
67
- body = part["body"]
68
-
69
- # Rewrite body to link to embedded attachments served by cid
70
- body.gsub! /cid:([^'"> ]+)/, "#{id}/parts/\\1"
71
-
72
- # Rewrite body to open links in a new window
73
- body.gsub! /<a\s+/, '<a target="_blank" '
74
16
 
75
- body
76
- else
77
- not_found
78
- end
79
- end
80
-
81
- get "/messages/:id.plain" do
82
- id = params[:id].to_i
83
- if part = MailCatcher::Mail.message_part_plain(id)
84
- content_type part["type"], :charset => (part["charset"] || "utf8")
85
- part["body"]
86
- else
87
- not_found
88
- end
89
- end
17
+ run Application
18
+ end
90
19
 
91
- get "/messages/:id.source" do
92
- id = params[:id].to_i
93
- if message = MailCatcher::Mail.message(id)
94
- content_type "text/plain"
95
- message["source"]
96
- else
97
- not_found
20
+ # This should only affect when http_path is anything but "/" above
21
+ run lambda { |env| [302, {"Location" => MailCatcher.options[:http_path]}, []] }
22
+ end
98
23
  end
99
- end
100
-
101
- get "/messages/:id.eml" do
102
- id = params[:id].to_i
103
- if message = MailCatcher::Mail.message(id)
104
- content_type "message/rfc822"
105
- message["source"]
106
- else
107
- not_found
108
- end
109
- end
110
24
 
111
- get "/messages/:id/parts/:cid" do
112
- id = params[:id].to_i
113
- if part = MailCatcher::Mail.message_part_cid(id, params[:cid])
114
- content_type part["type"], :charset => (part["charset"] || "utf8")
115
- attachment part["filename"] if part["is_attachment"] == 1
116
- body part["body"].to_s
117
- else
118
- not_found
25
+ def call(env)
26
+ app.call(env)
119
27
  end
120
28
  end
121
-
122
- get "/messages/:id/analysis.?:format?" do
123
- id = params[:id].to_i
124
- if part = MailCatcher::Mail.message_part_html(id)
125
- # TODO: Server-side cache? Make the browser cache based on message create time? Hmm.
126
- uri = URI.parse("http://api.getfractal.com/api/v2/validate#{"/format/#{params[:format]}" if params[:format].present?}")
127
- response = Net::HTTP.post_form(uri, :api_key => "5c463877265251386f516f7428", :html => part["body"])
128
- content_type ".#{params[:format]}" if params[:format].present?
129
- body response.body
130
- else
131
- not_found
132
- end
133
- end
134
-
135
- delete '/messages/:id' do
136
- id = params[:id].to_i
137
- if message = MailCatcher::Mail.message(id)
138
- MailCatcher::Mail.delete_message!(id)
139
- status 204
140
- else
141
- not_found
142
- end
143
- end
144
-
145
- not_found do
146
- "<html><body><h1>No Dice</h1><p>The message you were looking for does not exist, or doesn't have content of this type.</p></body></html>"
147
- end
148
29
  end
data/lib/mail_catcher.rb CHANGED
@@ -1,67 +1,98 @@
1
- require 'active_support/core_ext'
2
- require 'eventmachine'
3
- require 'open3'
4
- require 'optparse'
5
- require 'rbconfig'
6
- require 'thin'
1
+ # frozen_string_literal: true
7
2
 
8
- require 'mail_catcher/version'
3
+ require "open3"
4
+ require "optparse"
5
+ require "rbconfig"
9
6
 
10
- module MailCatcher extend self
11
- def which command
12
- not windows? and Open3.popen3 'which', 'command' do |stdin, stdout, stderr|
13
- return stdout.read.chomp.presence
14
- end
15
- end
7
+ require "eventmachine"
8
+ require "thin"
16
9
 
17
- def mac?
18
- RbConfig::CONFIG['host_os'] =~ /darwin/
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)
19
15
  end
16
+ end
20
17
 
21
- def windows?
22
- RbConfig::CONFIG['host_os'] =~ /mswin|mingw/
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")
23
28
  end
24
29
 
25
- def macruby?
26
- mac? and const_defined? :MACRUBY_VERSION
30
+ def development?
31
+ env == "development"
27
32
  end
28
33
 
29
- def growlnotify?
30
- which "growlnotify"
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
31
38
  end
32
39
 
33
- def growl?
34
- growlnotify?
40
+ def windows?
41
+ RbConfig::CONFIG["host_os"] =~ /mswin|mingw/
35
42
  end
36
43
 
37
- def browse?
38
- windows? or which "open"
44
+ def browseable?
45
+ windows? or which? "open"
39
46
  end
40
47
 
41
48
  def browse url
42
49
  if windows?
43
50
  system "start", "/b", url
44
- elsif which "open"
51
+ elsif which? "open"
45
52
  system "open", url
46
53
  end
47
54
  end
48
55
 
49
- @defaults = {
50
- :smtp_ip => '127.0.0.1',
51
- :smtp_port => '1025',
52
- :http_ip => '127.0.0.1',
53
- :http_port => '1080',
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,
54
74
  :verbose => false,
55
75
  :daemon => !windows?,
56
- :growl => growlnotify?,
57
76
  :browse => false,
77
+ :quit => true,
58
78
  }
59
79
 
80
+ def options
81
+ @@options
82
+ end
83
+
84
+ def quittable?
85
+ options[:quit]
86
+ end
87
+
60
88
  def parse! arguments=ARGV, defaults=@defaults
61
- @defaults.dup.tap do |options|
89
+ @@defaults.dup.tap do |options|
62
90
  OptionParser.new do |parser|
63
91
  parser.banner = "Usage: mailcatcher [options]"
64
92
  parser.version = VERSION
93
+ parser.separator ""
94
+ parser.separator "MailCatcher v#{VERSION}"
95
+ parser.separator ""
65
96
 
66
97
  parser.on("--ip IP", "Set the ip address of both servers") do |ip|
67
98
  options[:smtp_ip] = options[:http_ip] = ip
@@ -83,38 +114,44 @@ module MailCatcher extend self
83
114
  options[:http_port] = port
84
115
  end
85
116
 
86
- if mac?
87
- parser.on("--[no-]growl", "Growl to the local machine when a message arrives") do |growl|
88
- if growl and not growlnotify?
89
- puts "You'll need to install growlnotify from the Growl installer."
90
- puts
91
- puts "See: http://growl.info/extras.php#growlnotify"
92
- exit!
93
- end
117
+ parser.on("--messages-limit COUNT", Integer, "Only keep up to COUNT most recent messages") do |count|
118
+ options[:messages_limit] = count
119
+ end
94
120
 
95
- options[:growl] = growl
96
- end
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
97
129
  end
98
130
 
99
131
  unless windows?
100
- parser.on('-f', '--foreground', 'Run in the foreground') do
132
+ parser.on("-f", "--foreground", "Run in the foreground") do
101
133
  options[:daemon] = false
102
134
  end
103
135
  end
104
136
 
105
- if browse?
106
- parser.on('-b', '--browse', 'Open web browser') do
137
+ if browseable?
138
+ parser.on("-b", "--browse", "Open web browser") do
107
139
  options[:browse] = true
108
140
  end
109
141
  end
110
142
 
111
- parser.on('-v', '--verbose', 'Be more verbose') do
143
+ parser.on("-v", "--verbose", "Be more verbose") do
112
144
  options[:verbose] = true
113
145
  end
114
146
 
115
- parser.on('-h', '--help', 'Display this help information') do
147
+ parser.on_tail("-h", "--help", "Display this help information") do
116
148
  puts parser
117
- exit!
149
+ exit
150
+ end
151
+
152
+ parser.on_tail("--version", "Display the current version") do
153
+ puts "MailCatcher v#{VERSION}"
154
+ exit
118
155
  end
119
156
  end.parse!
120
157
  end
@@ -122,22 +159,25 @@ module MailCatcher extend self
122
159
 
123
160
  def run! options=nil
124
161
  # If we are passed options, fill in the blanks
125
- options &&= @defaults.merge options
162
+ options &&= @@defaults.merge options
126
163
  # Otherwise, parse them from ARGV
127
164
  options ||= parse!
128
165
 
129
- puts "Starting MailCatcher"
166
+ # Stash them away for later
167
+ @@options = options
130
168
 
131
- Thin::Logging.silent = true
169
+ # If we're running in the foreground sync the output.
170
+ unless options[:daemon]
171
+ $stdout.sync = $stderr.sync = true
172
+ end
132
173
 
133
- # One EventMachine loop...
134
- EventMachine.run do
135
- # Get our lion on if asked
136
- MailCatcher::Growl.start if options[:growl]
174
+ puts "Starting MailCatcher v#{VERSION}"
137
175
 
138
- smtp_url = "smtp://#{options[:smtp_ip]}:#{options[:smtp_port]}"
139
- http_url = "http://#{options[:http_ip]}:#{options[:http_port]}"
176
+ Thin::Logging.debug = development?
177
+ Thin::Logging.silent = !development?
140
178
 
179
+ # One EventMachine loop...
180
+ EventMachine.run do
141
181
  # Set up an SMTP server to run within EventMachine
142
182
  rescue_port options[:smtp_port] do
143
183
  EventMachine.start_server options[:smtp_ip], options[:smtp_port], Smtp
@@ -147,7 +187,7 @@ module MailCatcher extend self
147
187
  # Let Thin set itself up inside our EventMachine loop
148
188
  # (Skinny/WebSockets just works on the inside)
149
189
  rescue_port options[:http_port] do
150
- Thin::Server.start options[:http_ip], options[:http_port], Web
190
+ Thin::Server.start(options[:http_ip], options[:http_port], Web)
151
191
  puts "==> #{http_url}"
152
192
  end
153
193
 
@@ -161,7 +201,11 @@ module MailCatcher extend self
161
201
  # Daemonize, if we should, but only after the servers have started.
162
202
  if options[:daemon]
163
203
  EventMachine.next_tick do
164
- puts "*** MailCatcher runs as a daemon by default. Go to the web interface to quit."
204
+ if quittable?
205
+ puts "*** MailCatcher runs as a daemon by default. Go to the web interface to quit."
206
+ else
207
+ puts "*** MailCatcher is now running as a daemon that cannot be quit."
208
+ end
165
209
  Process.daemon
166
210
  end
167
211
  end
@@ -169,11 +213,21 @@ module MailCatcher extend self
169
213
  end
170
214
 
171
215
  def quit!
216
+ MailCatcher::Bus.push(type: "quit")
217
+
172
218
  EventMachine.next_tick { EventMachine.stop_event_loop }
173
219
  end
174
220
 
175
221
  protected
176
222
 
223
+ def smtp_url
224
+ "smtp://#{@@options[:smtp_ip]}:#{@@options[:smtp_port]}"
225
+ end
226
+
227
+ def http_url
228
+ "http://#{@@options[:http_ip]}:#{@@options[:http_port]}#{@@options[:http_path]}".chomp("/")
229
+ end
230
+
177
231
  def rescue_port port
178
232
  begin
179
233
  yield
@@ -182,16 +236,12 @@ protected
182
236
  rescue RuntimeError
183
237
  if $!.to_s =~ /\bno acceptor\b/
184
238
  puts "~~> ERROR: Something's using port #{port}. Are you already running MailCatcher?"
185
- exit(-1)
239
+ puts "==> #{smtp_url}"
240
+ puts "==> #{http_url}"
241
+ exit -1
186
242
  else
187
243
  raise
188
244
  end
189
245
  end
190
246
  end
191
247
  end
192
-
193
- require 'mail_catcher/events'
194
- require 'mail_catcher/growl'
195
- require 'mail_catcher/mail'
196
- require 'mail_catcher/smtp'
197
- require 'mail_catcher/web'
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mail_catcher"
4
+
5
+ Mailcatcher = MailCatcher
Binary file
Binary file
Binary file
@@ -0,0 +1 @@
1
+ html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font:inherit;font-size:100%;vertical-align:baseline}html{line-height:1}ol,ul{list-style:none}table{border-collapse:collapse;border-spacing:0}caption,th,td{text-align:left;font-weight:normal;vertical-align:middle}q,blockquote{quotes:none}q:before,q:after,blockquote:before,blockquote:after{content:"";content:none}a img{border:none}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}html,body{width:100%;height:100%}body{display:-moz-box;display:-webkit-box;display:box;-moz-box-orient:vertical;-webkit-box-orient:vertical;box-orient:vertical;background:#eee;color:#000;font-size:12px;font-family:Helvetica, sans-serif}body html{font-size:75%;line-height:1.5em}body.iframe{background:#fff}body.iframe h1{font-size:1.3em;margin:12px}body.iframe p,body.iframe form{margin:0 12px 12px 12px;line-height:1.25}body.iframe .loading{color:#666;margin-left:0.5em}.button{padding:0.5em 1em;border:1px solid #ccc;-moz-border-radius:2px;-webkit-border-radius:2px;border-radius:2px;background:url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4gPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJncmFkIiBncmFkaWVudFVuaXRzPSJvYmplY3RCb3VuZGluZ0JveCIgeDE9IjAuNSIgeTE9IjAuMCIgeDI9IjAuNSIgeTI9IjEuMCI+PHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iI2Y0ZjRmNCIvPjxzdG9wIG9mZnNldD0iMTAwJSIgc3RvcC1jb2xvcj0iI2VjZWNlYyIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9InVybCgjZ3JhZCkiIC8+PC9zdmc+IA=="),#ececec;background:-webkit-gradient(linear, 50% 0%, 50% 100%, color-stop(0%, #f4f4f4), color-stop(100%, #ececec)),#ececec;background:-moz-linear-gradient(#f4f4f4, #ececec),#ececec;background:-webkit-linear-gradient(#f4f4f4, #ececec),#ececec;background:linear-gradient(#f4f4f4, #ececec),#ececec;color:#666;text-shadow:1px 1px 0 #fff;text-decoration:none}.button:hover,.button:focus{border-color:#999;border-bottom-color:#666;background:url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4gPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJncmFkIiBncmFkaWVudFVuaXRzPSJvYmplY3RCb3VuZGluZ0JveCIgeDE9IjAuNSIgeTE9IjAuMCIgeDI9IjAuNSIgeTI9IjEuMCI+PHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iI2VlZWVlZSIvPjxzdG9wIG9mZnNldD0iMTAwJSIgc3RvcC1jb2xvcj0iI2RkZGRkZCIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9InVybCgjZ3JhZCkiIC8+PC9zdmc+IA=="),#ddd;background:-webkit-gradient(linear, 50% 0%, 50% 100%, color-stop(0%, #eee), color-stop(100%, #ddd)),#ddd;background:-moz-linear-gradient(#eee, #ddd),#ddd;background:-webkit-linear-gradient(#eee, #ddd),#ddd;background:linear-gradient(#eee, #ddd),#ddd;color:#333;text-decoration:none}.button:active,.button.active{border-color:#666;border-bottom-color:#999;background:url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4gPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJncmFkIiBncmFkaWVudFVuaXRzPSJvYmplY3RCb3VuZGluZ0JveCIgeDE9IjAuNSIgeTE9IjAuMCIgeDI9IjAuNSIgeTI9IjEuMCI+PHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iI2RkZGRkZCIvPjxzdG9wIG9mZnNldD0iMTAwJSIgc3RvcC1jb2xvcj0iI2VlZWVlZSIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9InVybCgjZ3JhZCkiIC8+PC9zdmc+IA=="),#eee;background:-webkit-gradient(linear, 50% 0%, 50% 100%, color-stop(0%, #ddd), color-stop(100%, #eee)),#eee;background:-moz-linear-gradient(#ddd, #eee),#eee;background:-webkit-linear-gradient(#ddd, #eee),#eee;background:linear-gradient(#ddd, #eee),#eee;color:#333;text-decoration:none;text-shadow:-1px -1px 0 #eee}body>header{overflow:hidden;*zoom:1;border-bottom:1px solid #ccc}body>header h1{float:left;margin-left:6px;padding:6px;padding-left:30px;background:url(logo.png) left no-repeat;background-size:24px 24px;font-size:18px;font-weight:bold}body>header h1 a{color:black;text-decoration:none;text-shadow:0 1px 0 white;-moz-transition:ease 0.1s;-o-transition:ease 0.1s;-webkit-transition:ease 0.1s;transition:ease 0.1s}body>header h1 a:hover{color:#4183C4}body>header nav{border-left:1px solid #ccc}body>header nav.project{float:left}body>header nav.app{float:right}body>header nav li{display:block;float:left;border-left:1px solid #fff;border-right:1px solid #ccc}body>header nav li input{margin:6px}body>header nav li a{display:block;padding:10px;text-decoration:none;text-shadow:0 1px 0 white;background:url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4gPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJncmFkIiBncmFkaWVudFVuaXRzPSJvYmplY3RCb3VuZGluZ0JveCIgeDE9IjAuNSIgeTE9IjAuMCIgeDI9IjAuNSIgeTI9IjEuMCI+PHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iI2Y0ZjRmNCIvPjxzdG9wIG9mZnNldD0iMTAwJSIgc3RvcC1jb2xvcj0iI2VjZWNlYyIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9InVybCgjZ3JhZCkiIC8+PC9zdmc+IA=="),#ececec;background:-webkit-gradient(linear, 50% 0%, 50% 100%, color-stop(0%, #f4f4f4), color-stop(100%, #ececec)),#ececec;background:-moz-linear-gradient(#f4f4f4, #ececec),#ececec;background:-webkit-linear-gradient(#f4f4f4, #ececec),#ececec;background:linear-gradient(#f4f4f4, #ececec),#ececec;color:#666;text-shadow:1px 1px 0 #fff;text-decoration:none}body>header nav li a:hover,body>header nav li a:focus{background:url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4gPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJncmFkIiBncmFkaWVudFVuaXRzPSJvYmplY3RCb3VuZGluZ0JveCIgeDE9IjAuNSIgeTE9IjAuMCIgeDI9IjAuNSIgeTI9IjEuMCI+PHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iI2VlZWVlZSIvPjxzdG9wIG9mZnNldD0iMTAwJSIgc3RvcC1jb2xvcj0iI2RkZGRkZCIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9InVybCgjZ3JhZCkiIC8+PC9zdmc+IA=="),#ddd;background:-webkit-gradient(linear, 50% 0%, 50% 100%, color-stop(0%, #eee), color-stop(100%, #ddd)),#ddd;background:-moz-linear-gradient(#eee, #ddd),#ddd;background:-webkit-linear-gradient(#eee, #ddd),#ddd;background:linear-gradient(#eee, #ddd),#ddd;color:#333;text-decoration:none}body>header nav li a:active,body>header nav li a.active{background:url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4gPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJncmFkIiBncmFkaWVudFVuaXRzPSJvYmplY3RCb3VuZGluZ0JveCIgeDE9IjAuNSIgeTE9IjAuMCIgeDI9IjAuNSIgeTI9IjEuMCI+PHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iI2RkZGRkZCIvPjxzdG9wIG9mZnNldD0iMTAwJSIgc3RvcC1jb2xvcj0iI2VlZWVlZSIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9InVybCgjZ3JhZCkiIC8+PC9zdmc+IA=="),#eee;background:-webkit-gradient(linear, 50% 0%, 50% 100%, color-stop(0%, #ddd), color-stop(100%, #eee)),#eee;background:-moz-linear-gradient(#ddd, #eee),#eee;background:-webkit-linear-gradient(#ddd, #eee),#eee;background:linear-gradient(#ddd, #eee),#eee;color:#333;text-decoration:none;text-shadow:-1px -1px 0 #eee}#messages{width:100%;height:10em;min-height:3em;overflow:auto;background:#fff;border-top:1px solid #fff}#messages table{overflow:hidden;*zoom:1;width:100%}#messages table thead tr{background:#eee;color:#333}#messages table thead tr th{padding:0.25em;font-weight:bold;color:#666;text-shadow:0 1px 0 white}#messages table tbody tr{cursor:pointer;-moz-transition:ease 0.1s;-o-transition:ease 0.1s;-webkit-transition:ease 0.1s;transition:ease 0.1s;color:#333}#messages table tbody tr:hover{color:#000}#messages table tbody tr:nth-child(even){background:#f0f0f0}#messages table tbody tr.selected{background:Highlight;color:HighlightText}#messages table tbody tr td{padding:0.25em}#messages table tbody tr td.blank{color:#666;font-style:italic}#resizer{padding-bottom:5px;cursor:ns-resize}#resizer .ruler{border-top:1px solid #ccc;border-bottom:1px solid #fff}#message{display:-moz-box;display:-webkit-box;display:box;-moz-box-orient:vertical;-webkit-box-orient:vertical;box-orient:vertical;-moz-box-flex:1;-webkit-box-flex:1;box-flex:1}#message>header{overflow:hidden;*zoom:1}#message>header .metadata{overflow:hidden;*zoom:1;padding:0.5em;padding-top:0}#message>header .metadata dt,#message>header .metadata dd{padding:0.25em}#message>header .metadata dt{float:left;clear:left;width:8em;margin-right:0.5em;text-align:right;font-weight:bold;color:#666;text-shadow:0 1px 0 white}#message>header .metadata dd.subject{font-weight:bold}#message>header .metadata .attachments{display:none}#message>header .metadata .attachments ul{display:inline}#message>header .metadata .attachments ul li{display:inline-block;vertical-align:middle;*vertical-align:auto;*zoom:1;*display:inline;margin-right:0.5em}#message>header .views ul{padding:0 0.5em;border-bottom:1px solid #ccc}#message>header .views .tab{display:inline-block;vertical-align:middle;*vertical-align:auto;*zoom:1;*display:inline}#message>header .views .tab a{display:inline-block;vertical-align:middle;*vertical-align:auto;*zoom:1;*display:inline;padding:0.5em;border:1px solid #ccc;background:#ddd;color:#333;border-width:1px 1px 0 1px;cursor:pointer;text-shadow:0 1px 0 #eeeeee;text-decoration:none}#message>header .views .tab:not(.selected):hover a{background-color:#eee}#message>header .views .tab.selected a{background:#fff;color:#000;height:13px;-moz-box-shadow:1px 1px 0 #ccc;-webkit-box-shadow:1px 1px 0 #ccc;box-shadow:1px 1px 0 #ccc;margin-bottom:-2px;cursor:default}#message>header .views .action{display:inline-block;vertical-align:middle;*vertical-align:auto;*zoom:1;*display:inline;float:right;margin:0 0.25em}.fractal-analysis{margin:12px 0}.fractal-analysis .report-intro{font-weight:bold}.fractal-analysis .report-intro.valid{color:#090}.fractal-analysis .report-intro.invalid{color:#c33}.fractal-analysis code{font-family:Monaco, "Courier New", Courier, monospace;background-color:#f8f8ff;color:#444;padding:0 0.2em;border:1px solid #dedede}.fractal-analysis ul{margin:1em 0 1em 1em;list-style-type:square}.fractal-analysis ol{margin:1em 0 1em 2em;list-style-type:decimal}.fractal-analysis ul li,.fractal-analysis ol li{display:list-item;margin:0.5em 0 0.5em 1em}.fractal-analysis .error-intro strong{font-weight:bold}.fractal-analysis .unsupported-clients dt{padding-left:1em}.fractal-analysis .unsupported-clients dd{padding-left:2em}.fractal-analysis .unsupported-clients dd ul li{display:list-item}iframe{display:-moz-box;display:-webkit-box;display:box;-moz-box-flex:1;-webkit-box-flex:1;box-flex:1;background:#fff}@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi){body>header h1{background-image:url(logo_2x.png)}}#noscript-overlay{position:absolute;top:0;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center;background-color:rgba(0,0,0,0.5);cursor:default;outline:none}#noscript{display:block;max-width:100%;margin:2rem;padding:2rem;border-radius:0.5rem;background-color:#fff;box-shadow:0 0 1rem 0 rgba(0,0,0,0.4)}