mailcatcher 0.5.12 → 0.8.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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)}