diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..10e39ec
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,23 @@
+# See https://help.github.com/articles/ignoring-files for more about ignoring files.
+#
+# If you find yourself ignoring temporary files generated by your text editor
+# or operating system, you probably want to add a global ignore instead:
+# git config --global core.excludesfile '~/.gitignore_global'
+
+# Ignore bundler config.
+/.bundle
+
+# Ignore all logfiles and tempfiles.
+/log/*
+/tmp/*
+!/log/.keep
+!/tmp/.keep
+
+# Ignore Byebug command history file.
+.byebug_history
+
+config/postal.yml
+config/smtp.cert
+config/smtp.key
+config/lets_encrypt.pem
+config/signing.key
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000..0c1a20f
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,44 @@
+source 'https://rubygems.org'
+gem 'rails', '= 5.0.2'
+gem 'mysql2', '>= 0.3.18', '< 0.5'
+gem 'puma', '~> 3.0'
+gem 'sass-rails', '~> 5.0'
+gem 'uglifier', '>= 1.3.0'
+gem 'coffee-rails', '~> 4.2'
+gem 'jquery-rails'
+gem 'turbolinks', '~> 5'
+gem 'haml'
+gem 'nifty-utils'
+gem 'nilify_blanks'
+gem 'kaminari'
+gem 'bcrypt'
+gem 'foreman'
+gem 'hashie'
+gem 'authie', :git => "git@codebasehq.com:atechmedia/libs/authie"
+gem 'dynamic_form'
+gem 'changey'
+gem 'mail'
+gem 'autoprefixer-rails'
+gem 'bunny', '~> 2.5.1'
+gem 'secure_headers'
+gem 'chronic'
+gem 'basic_ssl'
+gem 'clockwork'
+gem 'encrypto_signo'
+gem 'epoll', :require => nil
+gem 'mongo'
+gem 'sentry-raven'
+gem 'gelf'
+gem 'moonrope', :git => 'https://github.com/adamcooke/moonrope', :branch => 'master'
+gem 'jwt'
+gem 'createsend', '~> 4.0'
+gem 'acme-client', :git => 'https://github.com/unixcharles/acme-client'
+
+group :development, :test do
+ gem 'byebug'
+end
+
+group :development do
+ gem 'web-console'
+ gem 'annotate'
+end
diff --git a/Gemfile.lock b/Gemfile.lock
new file mode 100644
index 0000000..a0b73cf
--- /dev/null
+++ b/Gemfile.lock
@@ -0,0 +1,257 @@
+GIT
+ remote: git@codebasehq.com:atechmedia/libs/authie
+ revision: 045adc2e54e3ec6ced99ad49356da6397dd62542
+ specs:
+ authie (3.0.0)
+
+GIT
+ remote: https://github.com/adamcooke/moonrope
+ revision: fe5d5de38a35c36416f256a41902a6984e147fe5
+ branch: master
+ specs:
+ moonrope (2.0.0)
+ deep_merge (~> 1.0)
+ json (~> 1.7)
+ rack (>= 1.4)
+
+GIT
+ remote: https://github.com/unixcharles/acme-client
+ revision: 9a80e9301acd3f17ebff6ceaf245e0c400d08519
+ specs:
+ acme-client (0.5.0)
+ faraday (~> 0.9, >= 0.9.1)
+
+GEM
+ remote: https://rubygems.org/
+ specs:
+ actioncable (5.0.2)
+ actionpack (= 5.0.2)
+ nio4r (>= 1.2, < 3.0)
+ websocket-driver (~> 0.6.1)
+ actionmailer (5.0.2)
+ actionpack (= 5.0.2)
+ actionview (= 5.0.2)
+ activejob (= 5.0.2)
+ mail (~> 2.5, >= 2.5.4)
+ rails-dom-testing (~> 2.0)
+ actionpack (5.0.2)
+ actionview (= 5.0.2)
+ activesupport (= 5.0.2)
+ rack (~> 2.0)
+ rack-test (~> 0.6.3)
+ rails-dom-testing (~> 2.0)
+ rails-html-sanitizer (~> 1.0, >= 1.0.2)
+ actionview (5.0.2)
+ activesupport (= 5.0.2)
+ builder (~> 3.1)
+ erubis (~> 2.7.0)
+ rails-dom-testing (~> 2.0)
+ rails-html-sanitizer (~> 1.0, >= 1.0.3)
+ activejob (5.0.2)
+ activesupport (= 5.0.2)
+ globalid (>= 0.3.6)
+ activemodel (5.0.2)
+ activesupport (= 5.0.2)
+ activerecord (5.0.2)
+ activemodel (= 5.0.2)
+ activesupport (= 5.0.2)
+ arel (~> 7.0)
+ activesupport (5.0.2)
+ concurrent-ruby (~> 1.0, >= 1.0.2)
+ i18n (~> 0.7)
+ minitest (~> 5.1)
+ tzinfo (~> 1.1)
+ amq-protocol (2.0.1)
+ annotate (2.7.1)
+ activerecord (>= 3.2, < 6.0)
+ rake (>= 10.4, < 12.0)
+ arel (7.1.4)
+ autoprefixer-rails (6.5.0.2)
+ execjs
+ basic_ssl (1.0.3)
+ bcrypt (3.1.11)
+ bson (4.0.4)
+ builder (3.2.3)
+ bunny (2.5.1)
+ amq-protocol (>= 2.0.1)
+ byebug (9.0.6)
+ changey (1.0.0)
+ activerecord (>= 4.2, < 6)
+ chronic (0.10.2)
+ clockwork (1.0.0)
+ activesupport
+ tzinfo
+ coffee-rails (4.2.1)
+ coffee-script (>= 2.2.0)
+ railties (>= 4.0.0, < 5.2.x)
+ coffee-script (2.4.1)
+ coffee-script-source
+ execjs
+ coffee-script-source (1.10.0)
+ concurrent-ruby (1.0.5)
+ createsend (4.1.0)
+ hashie (~> 3.0)
+ httparty (~> 0.10)
+ json (~> 1.0)
+ debug_inspector (0.0.2)
+ deep_merge (1.1.1)
+ dynamic_form (1.1.4)
+ encrypto_signo (1.0.0)
+ epoll (0.3.0)
+ erubis (2.7.0)
+ execjs (2.7.0)
+ faraday (0.9.2)
+ multipart-post (>= 1.2, < 3)
+ foreman (0.82.0)
+ thor (~> 0.19.1)
+ gelf (3.0.0)
+ json
+ globalid (0.3.7)
+ activesupport (>= 4.1.0)
+ haml (4.0.7)
+ tilt
+ hashie (3.4.6)
+ httparty (0.14.0)
+ multi_xml (>= 0.5.2)
+ i18n (0.8.1)
+ jquery-rails (4.2.1)
+ rails-dom-testing (>= 1, < 3)
+ railties (>= 4.2.0)
+ thor (>= 0.14, < 2.0)
+ json (1.8.6)
+ jwt (1.5.6)
+ kaminari (0.17.0)
+ actionpack (>= 3.0.0)
+ activesupport (>= 3.0.0)
+ loofah (2.0.3)
+ nokogiri (>= 1.5.9)
+ mail (2.6.4)
+ mime-types (>= 1.16, < 4)
+ method_source (0.8.2)
+ mime-types (3.1)
+ mime-types-data (~> 3.2015)
+ mime-types-data (3.2016.0521)
+ mini_portile2 (2.1.0)
+ minitest (5.10.1)
+ mongo (2.2.4)
+ bson (~> 4.0)
+ multi_xml (0.5.5)
+ multipart-post (2.0.0)
+ mysql2 (0.4.4)
+ nifty-utils (1.1.7)
+ nilify_blanks (1.2.1)
+ activerecord (>= 3.0.0)
+ activesupport (>= 3.0.0)
+ nio4r (2.0.0)
+ nokogiri (1.7.1)
+ mini_portile2 (~> 2.1.0)
+ puma (3.8.2)
+ rack (2.0.1)
+ rack-test (0.6.3)
+ rack (>= 1.0)
+ rails (5.0.2)
+ actioncable (= 5.0.2)
+ actionmailer (= 5.0.2)
+ actionpack (= 5.0.2)
+ actionview (= 5.0.2)
+ activejob (= 5.0.2)
+ activemodel (= 5.0.2)
+ activerecord (= 5.0.2)
+ activesupport (= 5.0.2)
+ bundler (>= 1.3.0, < 2.0)
+ railties (= 5.0.2)
+ sprockets-rails (>= 2.0.0)
+ rails-dom-testing (2.0.2)
+ activesupport (>= 4.2.0, < 6.0)
+ nokogiri (~> 1.6)
+ rails-html-sanitizer (1.0.3)
+ loofah (~> 2.0)
+ railties (5.0.2)
+ actionpack (= 5.0.2)
+ activesupport (= 5.0.2)
+ method_source
+ rake (>= 0.8.7)
+ thor (>= 0.18.1, < 2.0)
+ rake (11.3.0)
+ sass (3.4.22)
+ sass-rails (5.0.6)
+ railties (>= 4.0.0, < 6)
+ sass (~> 3.1)
+ sprockets (>= 2.8, < 4.0)
+ sprockets-rails (>= 2.0, < 4.0)
+ tilt (>= 1.1, < 3)
+ secure_headers (3.4.1)
+ useragent
+ sentry-raven (2.1.0)
+ faraday (>= 0.7.6, < 0.10.x)
+ sprockets (3.7.1)
+ concurrent-ruby (~> 1.0)
+ rack (> 1, < 3)
+ sprockets-rails (3.2.0)
+ actionpack (>= 4.0)
+ activesupport (>= 4.0)
+ sprockets (>= 3.0.0)
+ thor (0.19.4)
+ thread_safe (0.3.6)
+ tilt (2.0.5)
+ turbolinks (5.0.1)
+ turbolinks-source (~> 5)
+ turbolinks-source (5.0.0)
+ tzinfo (1.2.3)
+ thread_safe (~> 0.1)
+ uglifier (3.0.2)
+ execjs (>= 0.3.0, < 3)
+ useragent (0.16.8)
+ web-console (3.3.1)
+ actionview (>= 5.0)
+ activemodel (>= 5.0)
+ debug_inspector
+ railties (>= 5.0)
+ websocket-driver (0.6.5)
+ websocket-extensions (>= 0.1.0)
+ websocket-extensions (0.1.2)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ acme-client!
+ annotate
+ authie!
+ autoprefixer-rails
+ basic_ssl
+ bcrypt
+ bunny (~> 2.5.1)
+ byebug
+ changey
+ chronic
+ clockwork
+ coffee-rails (~> 4.2)
+ createsend (~> 4.0)
+ dynamic_form
+ encrypto_signo
+ epoll
+ foreman
+ gelf
+ haml
+ hashie
+ jquery-rails
+ jwt
+ kaminari
+ mail
+ mongo
+ moonrope!
+ mysql2 (>= 0.3.18, < 0.5)
+ nifty-utils
+ nilify_blanks
+ puma (~> 3.0)
+ rails (= 5.0.2)
+ sass-rails (~> 5.0)
+ secure_headers
+ sentry-raven
+ turbolinks (~> 5)
+ uglifier (>= 1.3.0)
+ web-console
+
+BUNDLED WITH
+ 1.14.5
diff --git a/Procfile b/Procfile
new file mode 100644
index 0000000..7998818
--- /dev/null
+++ b/Procfile
@@ -0,0 +1,6 @@
+web: bundle exec puma -C config/puma.rb
+fast: bundle exec rake postal:fast_server
+worker: bundle exec rake postal:worker
+cron: bundle exec rake postal:cron
+smtp: bundle exec rake postal:smtp_server
+requeuer: bundle exec rake postal:requeuer
diff --git a/Procfile.options b/Procfile.options
new file mode 100644
index 0000000..7262d7e
--- /dev/null
+++ b/Procfile.options
@@ -0,0 +1,14 @@
+app_name: Postal
+log_path: log/procodile.log
+pid_root: tmp/pids
+processes:
+ web:
+ restart_mode: usr1
+ fast:
+ restart_mode: term-start
+ smtp:
+ restart_mode: usr1
+ worker:
+ restart_mode: start-term
+ cron:
+ restart_mode: term-start
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..61a6079
--- /dev/null
+++ b/README.md
@@ -0,0 +1,9 @@
+# Postal
+
+Postal is a complete and fully featured mail server for use by websites & web servers. Think Sendgrid, Mailgun or Postmark but open source and ready for you to run on your own servers. Postal was developed by aTech Media to serve its own mail processing requirements and we have since decided that it should be released as an open source project for the community. It was originally launched by us as AppMail but renamed to Postal as part of making it open source as we felt the name was more suitable.
+
+The application has been running in production for us for nearly 6 months and we will be continuing to use it ourselves and support its ongoing development.
+
+We are still just putting a few finishing touches to the documentation & installation instructions but we'll make it as easy as possible.
+
+Standby.
diff --git a/Rakefile b/Rakefile
new file mode 100644
index 0000000..e85f913
--- /dev/null
+++ b/Rakefile
@@ -0,0 +1,6 @@
+# Add your own tasks in files placed in lib/tasks ending in .rake,
+# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
+
+require_relative 'config/application'
+
+Rails.application.load_tasks
diff --git a/api/authenticator.rb b/api/authenticator.rb
new file mode 100644
index 0000000..7429b00
--- /dev/null
+++ b/api/authenticator.rb
@@ -0,0 +1,28 @@
+authenticator :server do
+ header "X-Server-API-Key", "The API token for a server that you wish to authenticate with.", :example => 'f29a45f0d4e1744ebaee'
+ error 'InvalidServerAPIKey', "The API token provided in X-Server-API-Key was not valid.", :attributes => {:token => "The token that was looked up"}
+ error 'ServerSuspended', "The mail server has been suspended"
+ lookup do
+ if key = request.headers['X-Server-API-Key']
+ if credential = Credential.where(:type => 'API', :key => key).first
+ if credential.server.suspended?
+ error 'ServerSuspended'
+ else
+ credential.use
+ credential
+ end
+ else
+ error 'InvalidServerAPIKey', :token => key
+ end
+ end
+ end
+ rule :default, "AccessDenied", "Must be authenticated as a server." do
+ identity.is_a?(Credential)
+ end
+end
+
+authenticator :anonymous do
+ rule :default, "MustNotBeAuthenticated", "Must not be authenticated." do
+ identity.nil?
+ end
+end
diff --git a/api/controllers/messages_api_controller.rb b/api/controllers/messages_api_controller.rb
new file mode 100644
index 0000000..d24c7cd
--- /dev/null
+++ b/api/controllers/messages_api_controller.rb
@@ -0,0 +1,40 @@
+controller :messages do
+ friendly_name "Messages API"
+ description "This API allows you to access message details"
+ authenticator :server
+
+ action :message do
+ title "Return message details"
+ description "Returns all details about a message"
+ param :id, "The ID of the message", :type => Integer, :required => true
+ returns Hash, :structure => :message, :structure_opts => {:paramable => {:expansions => false}}
+ error 'MessageNotFound', "No message found matching provided ID", :attributes => {:id => "The ID of the message"}
+ action do
+ begin
+ message = identity.server.message(params.id)
+ rescue Postal::MessageDB::Message::NotFound => e
+ error 'MessageNotFound', :id => params.id
+ end
+ structure :message, message, :return => true
+ end
+ end
+
+ action :deliveries do
+ title "Return deliveries for a message"
+ description "Returns an array of deliveries which have been attempted for this message"
+ param :id, "The ID of the message", :type => Integer, :required => true
+ returns Array, :structure => :delivery, :structure_opts => {:full => true}
+ error 'MessageNotFound', "No message found matching provided ID", :attributes => {:id => "The ID of the message"}
+ action do
+ begin
+ message = identity.server.message(params.id)
+ rescue Postal::MessageDB::Message::NotFound => e
+ error 'MessageNotFound', :id => params.id
+ end
+ message.deliveries.map do |d|
+ structure :delivery, d
+ end
+ end
+ end
+
+end
diff --git a/api/controllers/send_api_controller.rb b/api/controllers/send_api_controller.rb
new file mode 100644
index 0000000..de45849
--- /dev/null
+++ b/api/controllers/send_api_controller.rb
@@ -0,0 +1,109 @@
+controller :send do
+ friendly_name "Send API"
+ description "This API allows you to send messages"
+ authenticator :server
+
+ action :message do
+ title "Send a message"
+ description "This action allows you to send a message by providing the appropriate options"
+ # Acceptable Parameters
+ param :to, "The e-mail addresses of the recipients (max 50)", :type => Array
+ param :cc, "The e-mail addresses of any CC contacts (max 50)", :type => Array
+ param :bcc, "The e-mail addresses of any BCC contacts (max 50)", :type => Array
+ param :from, "The e-mail address of the sender", :type => String
+ param :subject, "The subject of the e-mail", :type => String
+ param :tag, "The tag of the e-mail", :type => String
+ param :reply_to, "Set the reply-to address for the mail", :type => String
+ param :plain_body, "The plain text body of the e-mail", :type => String
+ param :html_body, "The HTML body of the e-mail", :type => String
+ param :attachments, "An array of attachments for this e-mail", :type => Array
+ param :headers, "A hash of additional headers", :type => Hash
+ param :bounce, "Is this message a bounce?", :type => :boolean
+ # Errors
+ error 'ValidationError', "The provided data was not sufficient to send an email", :attributes => {:errors => "A hash of error details"}
+ error 'NoRecipients', "There are no recipients defined to received this message"
+ error 'NoContent', "There is no content defined for this e-mail"
+ error 'TooManyToAddresses', "The maximum number of To addresses has been reached (maximum 50)"
+ error 'TooManyCCAddresses', "The maximum number of CC addresses has been reached (maximum 50)"
+ error 'TooManyBCCAddresses', "The maximum number of BCC addresses has been reached (maximum 50)"
+ error 'FromAddressMissing', "The From address is missing and is required"
+ error 'UnauthenticatedFromAddress', "The From address is not authorised to send mail from this server"
+ error 'AttachmentMissingName', "An attachment is missing a name"
+ error 'AttachmentMissingData', "An attachment is missing data"
+ # Return
+ returns Hash
+ # Action
+ action do
+ attributes = {}
+ attributes[:to] = params.to
+ attributes[:cc] = params.cc
+ attributes[:bcc] = params.bcc
+ attributes[:from] = params.from
+ attributes[:sender] = params.sender
+ attributes[:subject] = params.subject
+ attributes[:reply_to] = params.reply_to
+ attributes[:plain_body] = params.plain_body
+ attributes[:html_body] = params.html_body
+ attributes[:bounce] = params.bounce ? true : false
+ attributes[:tag] = params.tag
+ attributes[:custom_headers] = params.headers
+ attributes[:attachments] = []
+ (params.attachments || []).each do |attachment|
+ next unless attachment.is_a?(Hash)
+ attributes[:attachments] << {:name => attachment['name'], :content_type => attachment['content_type'], :data => attachment['data'], :base64 => true}
+ end
+ message = OutgoingMessagePrototype.new(identity.server, request.ip, 'api', attributes)
+ message.credential = identity
+ if message.valid?
+ result = message.create_messages
+ {:message_id => message.message_id, :messages => result}
+ else
+ error message.errors.first
+ end
+ end
+ end
+
+ action :raw do
+ title "Send a raw RFC2882 message"
+ description "This action allows you to send us a raw RFC2822 formatted message along with the recipients that it should be sent to. This is similar to sending a message through our SMTP service."
+ param :mail_from, "The address that should be logged as sending the message", :type => String, :required => true
+ param :rcpt_to, "The addresses this message should be sent to", :type => Array, :required => true
+ param :data, "A base64 encoded RFC2822 message to send", :type => String, :required => true
+ param :bounce, "Is this message a bounce?", :type => :boolean
+ returns Hash
+ error 'UnauthenticatedFromAddress', "The From address is not authorised to send mail from this server"
+ action do
+ # Decode the raw message
+ raw_message = Base64.decode64(params.data)
+
+ # Parse through mail to get the from/sender headers
+ mail = Mail.new(raw_message.split("\r\n\r\n", 2).first)
+ from_headers = {'from' => mail.from, 'sender' => mail.sender}
+ authenticated_domain = identity.server.find_authenticated_domain_from_headers(from_headers)
+
+ # If we're not authenticated, don't continue
+ if authenticated_domain.nil?
+ error 'UnauthenticatedFromAddress'
+ end
+
+ # Store the result ready to return
+ result = {:message_id => nil, :messages => {}}
+ params.rcpt_to.uniq.each do |rcpt_to|
+ message = identity.server.message_db.new_message
+ message.rcpt_to = rcpt_to
+ message.mail_from = params.mail_from
+ message.raw_message = raw_message
+ message.received_with_ssl = true
+ message.scope = 'outgoing'
+ message.domain_id = authenticated_domain.id
+ message.credential_id = identity.id
+ message.bounce = params.bounce ? 1 : 0
+ message.save
+ result[:message_id] = message.message_id if result[:message_id].nil?
+ result[:messages][rcpt_to] = {:id => message.id, :token => message.token}
+ end
+ result
+ end
+ end
+
+end
diff --git a/api/structures/delivery_api_structure.rb b/api/structures/delivery_api_structure.rb
new file mode 100644
index 0000000..1f845a9
--- /dev/null
+++ b/api/structures/delivery_api_structure.rb
@@ -0,0 +1,10 @@
+structure :delivery do
+ basic :id
+ basic :status
+ basic :details
+ basic :output, :value => proc { o.output&.strip }
+ basic :sent_with_ssl, :value => proc { o.sent_with_ssl == 1 }
+ basic :log_id
+ basic :time, :value => proc { o.time&.to_f }
+ basic :timestamp, :value => proc { o.timestamp.to_f }
+end
diff --git a/api/structures/message_api_structure.rb b/api/structures/message_api_structure.rb
new file mode 100644
index 0000000..5e34ff1
--- /dev/null
+++ b/api/structures/message_api_structure.rb
@@ -0,0 +1,59 @@
+structure :message do
+ basic :id
+ basic :token
+
+ expansion(:status) {
+ {
+ :status => o.status,
+ :last_delivery_attempt => o.last_delivery_attempt ? o.last_delivery_attempt.to_f : nil,
+ :held => o.held == 1 ? true : false,
+ :hold_expiry => o.hold_expiry ? o.hold_expiry.to_f : nil
+ }
+ }
+
+ expansion(:details) {
+ {
+ :rcpt_to => o.rcpt_to,
+ :mail_from => o.mail_from,
+ :subject => o.subject,
+ :message_id => o.message_id,
+ :timestamp => o.timestamp.to_f,
+ :direction => o.scope,
+ :size => o.size,
+ :bounce => o.bounce,
+ :bounce_for_id => o.bounce_for_id,
+ :tag => o.tag,
+ :received_with_ssl => o.received_with_ssl
+ }
+ }
+
+ expansion(:inspection) {
+ {
+ :inspected => o.inspected == 1 ? true : false,
+ :spam => o.spam == 1 ? true : false,
+ :spam_score => o.spam_score.to_f,
+ :threat => o.threat == 1 ? true : false,
+ :threat_details => o.threat_details
+ }
+ }
+
+ expansion(:plain_body) { o.plain_body }
+
+ expansion(:html_body) { o.html_body }
+
+ expansion(:attachments) {
+ o.attachments.map do |attachment|
+ {
+ :filename => attachment.filename.to_s,
+ :content_type => attachment.mime_type,
+ :data => Base64.encode64(attachment.body.to_s),
+ :size => attachment.body.to_s.bytesize,
+ :hash => Digest::SHA1.hexdigest(attachment.body.to_s)
+ }
+ end
+ }
+
+ expansion(:headers) { o.headers }
+
+ expansion(:raw_message) { Base64.encode64(o.raw_message) }
+end
diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js
new file mode 100644
index 0000000..b16e53d
--- /dev/null
+++ b/app/assets/config/manifest.js
@@ -0,0 +1,3 @@
+//= link_tree ../images
+//= link_directory ../javascripts .js
+//= link_directory ../stylesheets .css
diff --git a/app/assets/fonts/DroidSansMono.eot b/app/assets/fonts/DroidSansMono.eot
new file mode 100644
index 0000000..7c2a63f
Binary files /dev/null and b/app/assets/fonts/DroidSansMono.eot differ
diff --git a/app/assets/fonts/DroidSansMono.ttf b/app/assets/fonts/DroidSansMono.ttf
new file mode 100644
index 0000000..6022784
Binary files /dev/null and b/app/assets/fonts/DroidSansMono.ttf differ
diff --git a/app/assets/fonts/DroidSansMono.woff b/app/assets/fonts/DroidSansMono.woff
new file mode 100644
index 0000000..241015b
Binary files /dev/null and b/app/assets/fonts/DroidSansMono.woff differ
diff --git a/app/assets/fonts/SourceSansPro-Black.eot b/app/assets/fonts/SourceSansPro-Black.eot
new file mode 100644
index 0000000..3d44cc2
Binary files /dev/null and b/app/assets/fonts/SourceSansPro-Black.eot differ
diff --git a/app/assets/fonts/SourceSansPro-Black.ttf b/app/assets/fonts/SourceSansPro-Black.ttf
new file mode 100644
index 0000000..479b40a
Binary files /dev/null and b/app/assets/fonts/SourceSansPro-Black.ttf differ
diff --git a/app/assets/fonts/SourceSansPro-Black.woff b/app/assets/fonts/SourceSansPro-Black.woff
new file mode 100644
index 0000000..6906484
Binary files /dev/null and b/app/assets/fonts/SourceSansPro-Black.woff differ
diff --git a/app/assets/fonts/SourceSansPro-Bold.eot b/app/assets/fonts/SourceSansPro-Bold.eot
new file mode 100644
index 0000000..1625d73
Binary files /dev/null and b/app/assets/fonts/SourceSansPro-Bold.eot differ
diff --git a/app/assets/fonts/SourceSansPro-Bold.ttf b/app/assets/fonts/SourceSansPro-Bold.ttf
new file mode 100644
index 0000000..07fae00
Binary files /dev/null and b/app/assets/fonts/SourceSansPro-Bold.ttf differ
diff --git a/app/assets/fonts/SourceSansPro-Bold.woff b/app/assets/fonts/SourceSansPro-Bold.woff
new file mode 100644
index 0000000..a186b83
Binary files /dev/null and b/app/assets/fonts/SourceSansPro-Bold.woff differ
diff --git a/app/assets/fonts/SourceSansPro-Light.eot b/app/assets/fonts/SourceSansPro-Light.eot
new file mode 100644
index 0000000..41d87ba
Binary files /dev/null and b/app/assets/fonts/SourceSansPro-Light.eot differ
diff --git a/app/assets/fonts/SourceSansPro-Light.ttf b/app/assets/fonts/SourceSansPro-Light.ttf
new file mode 100644
index 0000000..032fecf
Binary files /dev/null and b/app/assets/fonts/SourceSansPro-Light.ttf differ
diff --git a/app/assets/fonts/SourceSansPro-Light.woff b/app/assets/fonts/SourceSansPro-Light.woff
new file mode 100644
index 0000000..555a118
Binary files /dev/null and b/app/assets/fonts/SourceSansPro-Light.woff differ
diff --git a/app/assets/fonts/SourceSansPro-Regular.eot b/app/assets/fonts/SourceSansPro-Regular.eot
new file mode 100644
index 0000000..b11724a
Binary files /dev/null and b/app/assets/fonts/SourceSansPro-Regular.eot differ
diff --git a/app/assets/fonts/SourceSansPro-Regular.ttf b/app/assets/fonts/SourceSansPro-Regular.ttf
new file mode 100644
index 0000000..4709466
Binary files /dev/null and b/app/assets/fonts/SourceSansPro-Regular.ttf differ
diff --git a/app/assets/fonts/SourceSansPro-Regular.woff b/app/assets/fonts/SourceSansPro-Regular.woff
new file mode 100644
index 0000000..7182e72
Binary files /dev/null and b/app/assets/fonts/SourceSansPro-Regular.woff differ
diff --git a/app/assets/fonts/SourceSansPro-Semibold.eot b/app/assets/fonts/SourceSansPro-Semibold.eot
new file mode 100644
index 0000000..54cafa9
Binary files /dev/null and b/app/assets/fonts/SourceSansPro-Semibold.eot differ
diff --git a/app/assets/fonts/SourceSansPro-Semibold.ttf b/app/assets/fonts/SourceSansPro-Semibold.ttf
new file mode 100644
index 0000000..4d7fedf
Binary files /dev/null and b/app/assets/fonts/SourceSansPro-Semibold.ttf differ
diff --git a/app/assets/fonts/SourceSansPro-Semibold.woff b/app/assets/fonts/SourceSansPro-Semibold.woff
new file mode 100644
index 0000000..373005e
Binary files /dev/null and b/app/assets/fonts/SourceSansPro-Semibold.woff differ
diff --git a/app/assets/images/.keep b/app/assets/images/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/app/assets/images/animals/cat.svg b/app/assets/images/animals/cat.svg
new file mode 100644
index 0000000..1ea0ad3
--- /dev/null
+++ b/app/assets/images/animals/cat.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/animals/cat2.svg b/app/assets/images/animals/cat2.svg
new file mode 100644
index 0000000..739e1e1
--- /dev/null
+++ b/app/assets/images/animals/cat2.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/animals/cat3.svg b/app/assets/images/animals/cat3.svg
new file mode 100644
index 0000000..1c3f5c9
--- /dev/null
+++ b/app/assets/images/animals/cat3.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/animals/cat4.svg b/app/assets/images/animals/cat4.svg
new file mode 100644
index 0000000..da1305c
--- /dev/null
+++ b/app/assets/images/animals/cat4.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/animals/cock.svg b/app/assets/images/animals/cock.svg
new file mode 100644
index 0000000..a53e63c
--- /dev/null
+++ b/app/assets/images/animals/cock.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/animals/deer.svg b/app/assets/images/animals/deer.svg
new file mode 100644
index 0000000..7d19c29
--- /dev/null
+++ b/app/assets/images/animals/deer.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/animals/dog.svg b/app/assets/images/animals/dog.svg
new file mode 100644
index 0000000..79d71bb
--- /dev/null
+++ b/app/assets/images/animals/dog.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/animals/fox-white.svg b/app/assets/images/animals/fox-white.svg
new file mode 100644
index 0000000..e1d2af4
--- /dev/null
+++ b/app/assets/images/animals/fox-white.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/animals/fox.svg b/app/assets/images/animals/fox.svg
new file mode 100644
index 0000000..a87a1a6
--- /dev/null
+++ b/app/assets/images/animals/fox.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/animals/goat.svg b/app/assets/images/animals/goat.svg
new file mode 100644
index 0000000..985a3aa
--- /dev/null
+++ b/app/assets/images/animals/goat.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/animals/koala.svg b/app/assets/images/animals/koala.svg
new file mode 100644
index 0000000..b6c9cb2
--- /dev/null
+++ b/app/assets/images/animals/koala.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/animals/lion.svg b/app/assets/images/animals/lion.svg
new file mode 100644
index 0000000..aae894b
--- /dev/null
+++ b/app/assets/images/animals/lion.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/animals/monkey.svg b/app/assets/images/animals/monkey.svg
new file mode 100644
index 0000000..56a6f9f
--- /dev/null
+++ b/app/assets/images/animals/monkey.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/animals/owl.svg b/app/assets/images/animals/owl.svg
new file mode 100644
index 0000000..649a2b6
--- /dev/null
+++ b/app/assets/images/animals/owl.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/animals/panda.svg b/app/assets/images/animals/panda.svg
new file mode 100644
index 0000000..debb550
--- /dev/null
+++ b/app/assets/images/animals/panda.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/animals/penguin.svg b/app/assets/images/animals/penguin.svg
new file mode 100644
index 0000000..60ca46d
--- /dev/null
+++ b/app/assets/images/animals/penguin.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/animals/wolf.svg b/app/assets/images/animals/wolf.svg
new file mode 100644
index 0000000..74b34fd
--- /dev/null
+++ b/app/assets/images/animals/wolf.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/button-spinner-danger.gif b/app/assets/images/button-spinner-danger.gif
new file mode 100644
index 0000000..cd99d28
Binary files /dev/null and b/app/assets/images/button-spinner-danger.gif differ
diff --git a/app/assets/images/button-spinner-dark.gif b/app/assets/images/button-spinner-dark.gif
new file mode 100644
index 0000000..c1b1009
Binary files /dev/null and b/app/assets/images/button-spinner-dark.gif differ
diff --git a/app/assets/images/button-spinner-neutral.gif b/app/assets/images/button-spinner-neutral.gif
new file mode 100644
index 0000000..23a0d7d
Binary files /dev/null and b/app/assets/images/button-spinner-neutral.gif differ
diff --git a/app/assets/images/button-spinner-positive.gif b/app/assets/images/button-spinner-positive.gif
new file mode 100644
index 0000000..17c6136
Binary files /dev/null and b/app/assets/images/button-spinner-positive.gif differ
diff --git a/app/assets/images/button-spinner.gif b/app/assets/images/button-spinner.gif
new file mode 100644
index 0000000..3ba47b1
Binary files /dev/null and b/app/assets/images/button-spinner.gif differ
diff --git a/app/assets/images/cards/amex.png b/app/assets/images/cards/amex.png
new file mode 100755
index 0000000..deb9433
Binary files /dev/null and b/app/assets/images/cards/amex.png differ
diff --git a/app/assets/images/cards/mastercard.png b/app/assets/images/cards/mastercard.png
new file mode 100755
index 0000000..f778f2d
Binary files /dev/null and b/app/assets/images/cards/mastercard.png differ
diff --git a/app/assets/images/cards/visa.png b/app/assets/images/cards/visa.png
new file mode 100755
index 0000000..f816fef
Binary files /dev/null and b/app/assets/images/cards/visa.png differ
diff --git a/app/assets/images/favicon.png b/app/assets/images/favicon.png
new file mode 100644
index 0000000..d726986
Binary files /dev/null and b/app/assets/images/favicon.png differ
diff --git a/app/assets/images/icons/bats-white.svg b/app/assets/images/icons/bats-white.svg
new file mode 100644
index 0000000..ef10cc2
--- /dev/null
+++ b/app/assets/images/icons/bats-white.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/icons/box-white.svg b/app/assets/images/icons/box-white.svg
new file mode 100644
index 0000000..524bfff
--- /dev/null
+++ b/app/assets/images/icons/box-white.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/icons/conveyor.svg b/app/assets/images/icons/conveyor.svg
new file mode 100644
index 0000000..b443bc1
--- /dev/null
+++ b/app/assets/images/icons/conveyor.svg
@@ -0,0 +1,15 @@
+
+
diff --git a/app/assets/images/icons/cross-grey.svg b/app/assets/images/icons/cross-grey.svg
new file mode 100644
index 0000000..e3f6063
--- /dev/null
+++ b/app/assets/images/icons/cross-grey.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/icons/cross-orange.svg b/app/assets/images/icons/cross-orange.svg
new file mode 100644
index 0000000..34ff181
--- /dev/null
+++ b/app/assets/images/icons/cross-orange.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/icons/drop-arrow-white.svg b/app/assets/images/icons/drop-arrow-white.svg
new file mode 100644
index 0000000..15897ce
--- /dev/null
+++ b/app/assets/images/icons/drop-arrow-white.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/icons/email.svg b/app/assets/images/icons/email.svg
new file mode 100644
index 0000000..ad021a6
--- /dev/null
+++ b/app/assets/images/icons/email.svg
@@ -0,0 +1,15 @@
+
+
diff --git a/app/assets/images/icons/eye.svg b/app/assets/images/icons/eye.svg
new file mode 100644
index 0000000..3d5c003
--- /dev/null
+++ b/app/assets/images/icons/eye.svg
@@ -0,0 +1,13 @@
+
+
\ No newline at end of file
diff --git a/app/assets/images/icons/help.svg b/app/assets/images/icons/help.svg
new file mode 100644
index 0000000..1591662
--- /dev/null
+++ b/app/assets/images/icons/help.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/icons/incoming-mail-white.svg b/app/assets/images/icons/incoming-mail-white.svg
new file mode 100644
index 0000000..4ef9035
--- /dev/null
+++ b/app/assets/images/icons/incoming-mail-white.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/icons/incoming-mail.svg b/app/assets/images/icons/incoming-mail.svg
new file mode 100644
index 0000000..074f943
--- /dev/null
+++ b/app/assets/images/icons/incoming-mail.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/icons/lock.svg b/app/assets/images/icons/lock.svg
new file mode 100644
index 0000000..96a3fa8
--- /dev/null
+++ b/app/assets/images/icons/lock.svg
@@ -0,0 +1,12 @@
+
+
diff --git a/app/assets/images/icons/mouse.svg b/app/assets/images/icons/mouse.svg
new file mode 100644
index 0000000..561a2de
--- /dev/null
+++ b/app/assets/images/icons/mouse.svg
@@ -0,0 +1,14 @@
+
+
diff --git a/app/assets/images/icons/organization-white.svg b/app/assets/images/icons/organization-white.svg
new file mode 100644
index 0000000..5c0f2ae
--- /dev/null
+++ b/app/assets/images/icons/organization-white.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/icons/outgoing-mail-white.svg b/app/assets/images/icons/outgoing-mail-white.svg
new file mode 100644
index 0000000..31abbb0
--- /dev/null
+++ b/app/assets/images/icons/outgoing-mail-white.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/icons/outgoing-mail.svg b/app/assets/images/icons/outgoing-mail.svg
new file mode 100644
index 0000000..8f6623a
--- /dev/null
+++ b/app/assets/images/icons/outgoing-mail.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/icons/pause-white.svg b/app/assets/images/icons/pause-white.svg
new file mode 100644
index 0000000..72e2c9f
--- /dev/null
+++ b/app/assets/images/icons/pause-white.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/icons/premium.svg b/app/assets/images/icons/premium.svg
new file mode 100644
index 0000000..a99f286
--- /dev/null
+++ b/app/assets/images/icons/premium.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/icons/return-path.svg b/app/assets/images/icons/return-path.svg
new file mode 100644
index 0000000..9283b13
--- /dev/null
+++ b/app/assets/images/icons/return-path.svg
@@ -0,0 +1,12 @@
+
+
diff --git a/app/assets/images/icons/search.svg b/app/assets/images/icons/search.svg
new file mode 100644
index 0000000..fa80fba
--- /dev/null
+++ b/app/assets/images/icons/search.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/icons/select-arrow.svg b/app/assets/images/icons/select-arrow.svg
new file mode 100644
index 0000000..5b59e1a
--- /dev/null
+++ b/app/assets/images/icons/select-arrow.svg
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/assets/images/icons/size-white.svg b/app/assets/images/icons/size-white.svg
new file mode 100644
index 0000000..9a6f759
--- /dev/null
+++ b/app/assets/images/icons/size-white.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/app/assets/images/icons/tick-green.svg b/app/assets/images/icons/tick-green.svg
new file mode 100644
index 0000000..86e91a4
--- /dev/null
+++ b/app/assets/images/icons/tick-green.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/icons/tick-grey.svg b/app/assets/images/icons/tick-grey.svg
new file mode 100644
index 0000000..85037d7
--- /dev/null
+++ b/app/assets/images/icons/tick-grey.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/icons/trash-white.svg b/app/assets/images/icons/trash-white.svg
new file mode 100644
index 0000000..3bf031d
--- /dev/null
+++ b/app/assets/images/icons/trash-white.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/icons/truck.svg b/app/assets/images/icons/truck.svg
new file mode 100644
index 0000000..42e30aa
--- /dev/null
+++ b/app/assets/images/icons/truck.svg
@@ -0,0 +1,14 @@
+
+
diff --git a/app/assets/images/icons/user-white.svg b/app/assets/images/icons/user-white.svg
new file mode 100644
index 0000000..a5cf92e
--- /dev/null
+++ b/app/assets/images/icons/user-white.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/icons/web.svg b/app/assets/images/icons/web.svg
new file mode 100644
index 0000000..9bfcab1
--- /dev/null
+++ b/app/assets/images/icons/web.svg
@@ -0,0 +1,27 @@
+
+
diff --git a/app/assets/images/logo-grey.svg b/app/assets/images/logo-grey.svg
new file mode 100644
index 0000000..be386b7
--- /dev/null
+++ b/app/assets/images/logo-grey.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/logo.svg b/app/assets/images/logo.svg
new file mode 100644
index 0000000..a950aed
--- /dev/null
+++ b/app/assets/images/logo.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/spinner-sub.gif b/app/assets/images/spinner-sub.gif
new file mode 100644
index 0000000..708fd5c
Binary files /dev/null and b/app/assets/images/spinner-sub.gif differ
diff --git a/app/assets/images/starter_pack.png b/app/assets/images/starter_pack.png
new file mode 100644
index 0000000..7b59d97
Binary files /dev/null and b/app/assets/images/starter_pack.png differ
diff --git a/app/assets/images/tracking_pixel.png b/app/assets/images/tracking_pixel.png
new file mode 100644
index 0000000..c2b862b
Binary files /dev/null and b/app/assets/images/tracking_pixel.png differ
diff --git a/app/assets/javascripts/application/application.coffee b/app/assets/javascripts/application/application.coffee
new file mode 100644
index 0000000..c0ea875
--- /dev/null
+++ b/app/assets/javascripts/application/application.coffee
@@ -0,0 +1,55 @@
+#= require jquery
+#= require jquery_ujs
+#= require turbolinks
+#= require_tree ./vendor/.
+#= require_self
+#= require_tree .
+
+$ ->
+
+ isFirefox = -> !!navigator.userAgent.match(/firefox/i)
+
+ $('html').addClass('browser-firefox') if isFirefox()
+
+ $(document).on 'turbolinks:load', ->
+ $('.js-multibox').multibox({inputCount: 6, classNames: {container: "multibox", input: 'input input--text multibox__input'}})
+
+ $(document).on 'keyup', (event)->
+ return if $(event.target).is('input, select, textarea')
+ if event.keyCode == 83
+ $('.js-focus-on-s').focus()
+ event.preventDefault()
+ if event.keyCode == 70
+ $('.js-focus-on-f').focus()
+ event.preventDefault()
+
+ $(document).on 'click', 'html.main .flashMessage', ->
+ $(this).hide 'fast', ->
+ $(this).remove()
+
+ $(document).on 'click', '.js-toggle-helpbox', ->
+ helpBox = $('.js-helpbox')
+ if helpBox.hasClass('is-hidden')
+ helpBox.removeClass('is-hidden')
+ else
+ helpBox.addClass('is-hidden')
+ return false
+
+ $(document).on 'input', 'input[type=range]', ->
+ value = $(this).val()
+ updateAttr = $(this).attr('data-update')
+ if updateAttr && updateAttr.length
+ $("." + $(this).attr('data-update')).text(parseFloat(value, 10).toFixed(1))
+
+ $(document).on 'change', '.js-checkbox-list-toggle', ->
+ $this = $(this)
+ value = $this.val()
+ $list = $this.parent().find('.checkboxList')
+ if value == 'false' then $list.show() else $list.hide()
+
+ $(document).on 'click', '.js-toggle', ->
+ $link = $(this)
+ element = $link.attr('data-element')
+ $(element, $link.parent()).toggle()
+ false
+
diff --git a/app/assets/javascripts/application/elements/ajax.coffee b/app/assets/javascripts/application/elements/ajax.coffee
new file mode 100644
index 0000000..d0a3e0c
--- /dev/null
+++ b/app/assets/javascripts/application/elements/ajax.coffee
@@ -0,0 +1,61 @@
+onStart = (event) ->
+ $('.flashMessage').remove()
+ $('input, select, textarea').blur()
+ $target = $(event.target)
+ if $target.is('form')
+ $('.js-form-submit', $target).addClass('is-spinning')
+ if $target.hasClass('button')
+ $($target).addClass('is-spinning')
+
+onComplete = (event, xhr)->
+ $target = $(event.target)
+ if xhr.responseJSON
+ data = xhr.responseJSON
+ if data.redirect_to
+ Turbolinks.clearCache()
+ Turbolinks.visit(data.redirect_to, {"action":"replace"})
+ console.log "Redirected to #{data.redirect_to}"
+
+ if data.alert
+ unSpin($target)
+ alert(data.alert)
+
+ if data.form_errors
+ if $target.is('form')
+ unSpin($target)
+ handleErrors($target, data.form_errors)
+
+ if data.flash
+ unSpin($target)
+ $('body .flashMessage').remove()
+ for key, value of data.flash
+ $message = $("
")
+ list = $('ul', html)
+ $.each errors, ->
+ list.append("
#{this}")
+ $('.formErrors', form).remove()
+ form.prepend($(html))
+ console.log errors
+
+$ ->
+ $.ajaxSettings.dataType = 'json'
+ $(document)
+ .on 'ajax:before', onStart
+ .on 'ajax:complete', onComplete
diff --git a/app/assets/javascripts/application/elements/mail_graph.coffee b/app/assets/javascripts/application/elements/mail_graph.coffee
new file mode 100644
index 0000000..4c52635
--- /dev/null
+++ b/app/assets/javascripts/application/elements/mail_graph.coffee
@@ -0,0 +1,32 @@
+$(document).on 'turbolinks:load', ->
+
+ mailGraph = $('.mailGraph')
+
+ if mailGraph.length
+ data = JSON.parse(mailGraph.attr('data-data'))
+ incomingMail = []
+ outgoingMail = []
+ for d in data
+ incomingMail.push(d.incoming)
+ outgoingMail.push(d.outgoing)
+
+ data =
+ series: [outgoingMail, incomingMail]
+ options =
+ fullWidth: true
+ axisY:
+ offset:40
+ axisX:
+ showGrid: false
+ offset: 0
+ showLabel: true
+ height: '230px'
+ showArea: true
+ high: if incomingMail? && incomingMail.length then undefined else 1000
+ chartPadding:
+ top:0
+ right:0
+ bottom:0
+ left:0
+
+ new Chartist.Line '.mailGraph__graph', data, options
diff --git a/app/assets/javascripts/application/elements/remembering.coffee b/app/assets/javascripts/application/elements/remembering.coffee
new file mode 100644
index 0000000..faad320
--- /dev/null
+++ b/app/assets/javascripts/application/elements/remembering.coffee
@@ -0,0 +1,8 @@
+$ ->
+ $(document).on 'click', '.js-remember a', ->
+ $parent = $(this).parents('.js-remember')
+ value = $(this).attr('data-remember')
+ $parent.remove()
+ if value == 'yes'
+ $.post('/persist')
+ false
diff --git a/app/assets/javascripts/application/elements/searchable.coffee b/app/assets/javascripts/application/elements/searchable.coffee
new file mode 100644
index 0000000..da9fadb
--- /dev/null
+++ b/app/assets/javascripts/application/elements/searchable.coffee
@@ -0,0 +1,113 @@
+ENTER = 13
+DOWN_ARROW = 40
+UP_ARROW = 38
+
+filterList = ($container, query) ->
+ $items = getItems($container)
+ index = $container.data('searchifyIndex')
+ re = new RegExp(query, 'g')
+ $matches = $items.filter (i, item) ->
+ value = $(item).data('value')
+ re.test(value)
+ $items.addClass('is-hidden').filter($matches).removeClass('is-hidden')
+ toggleState($container, $matches.length > 0)
+ if index?
+ index = 0
+ $container.data('searchifyIndex', index)
+ highlightItem($container, $matches, index)
+
+getContainer = ($el) ->
+ $el.closest('.js-searchable')
+
+getEmpty = ($container) ->
+ $('.js-searchable__empty', $container)
+
+getList = ($container) ->
+ $('.js-searchable__list', $container)
+
+getItems = ($container) ->
+ $('.js-searchable__item', $container)
+
+highlightItem = ($container, $scope, index) ->
+ $items = getItems($container)
+ $items.removeClass('is-highlighted')
+ $scope.eq(index).addClass('is-highlighted') if index? && $scope.length
+
+highlightNext = ($container) ->
+ $matches = getMatches($container)
+ index = $container.data('searchifyIndex')
+ return unless $matches.length
+ if index?
+ return if index == $matches.length - 1
+ newIndex = index + 1
+ else
+ newIndex = 0
+ $container.data('searchifyIndex', newIndex)
+ highlightItem($container, $matches, newIndex)
+
+highlightPrev = ($container) ->
+ $matches = getMatches($container)
+ index = $container.data('searchifyIndex')
+ return unless $matches.length
+ if index?
+ return if index == 0
+ newIndex = index - 1
+ else
+ newIndex = 0
+ $container.data('searchifyIndex', newIndex)
+ highlightItem($container, $matches, newIndex)
+
+getMatches = ($container) ->
+ $items = getItems($container)
+ $items.filter(':not(.is-hidden)')
+
+searchify = (str) ->
+ str.toLowerCase().replace(/\W/g, '')
+
+selectHighlighted = ($container) ->
+ index = $container.data('searchifyIndex')
+ $matches = getMatches($container)
+ return unless index? && $matches.length
+ url = $matches.eq(index).data('url')
+ Turbolinks.visit(url)
+
+showAll = ($container) ->
+ $items = getItems($container)
+ index = $container.data('searchifyIndex')
+ $items.removeClass('is-hidden')
+ toggleState($container, true)
+ if index?
+ index = 0
+ $container.data('searchifyIndex', index)
+ highlightItem($container, $items, index)
+
+toggleState = ($container, predicate) ->
+ $empty = getEmpty($container)
+ $list = getList($container)
+ $empty.toggleClass('is-hidden', predicate)
+ $list.toggleClass('is-hidden', !predicate)
+
+# Event Handlers
+
+handleInput = (event) ->
+ $input = $(event.target)
+ $container = getContainer($input)
+ query = searchify($input.val())
+ if query.length then filterList($container, query) else showAll($container)
+
+handleKeydown = (event) ->
+ $container = getContainer($(event.target))
+ keyCode = event.keyCode
+ if keyCode == DOWN_ARROW
+ event.preventDefault()
+ highlightNext($container)
+ else if keyCode == ENTER
+ event.preventDefault()
+ selectHighlighted($container)
+ else if keyCode == UP_ARROW
+ event.preventDefault()
+ highlightPrev($container)
+$ ->
+ $(document)
+ .on('input', '.js-searchable__input', handleInput)
+ .on('keydown', '.js-searchable__input', handleKeydown)
diff --git a/app/assets/javascripts/application/modules/helpers.coffee b/app/assets/javascripts/application/modules/helpers.coffee
new file mode 100644
index 0000000..b33093a
--- /dev/null
+++ b/app/assets/javascripts/application/modules/helpers.coffee
@@ -0,0 +1,29 @@
+Postal.Helpers =
+ numberWithDelimiters: (number)->
+ number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")
+
+ pluralize: (number, word)->
+ if number == 1
+ "1 #{word}"
+ else
+ "#{number} #{word}s"
+
+ numberToHumanSize: (sizeInBytes)->
+ wholes = Math.floor(Math.log(sizeInBytes) / Math.log(1024))
+ unit = ['bytes', 'KB', 'MB', 'GB', 'TB'][wholes]
+ i = (sizeInBytes / Math.pow(1024, wholes))
+ if unit
+ i = if unit == 'bytes' then i.toFixed(0) else i.toFixed(2)
+ "#{i} #{unit}"
+ else
+ "0 bytes"
+
+ styleWidth: (width)->
+ width = 100 if width > 100.0
+ width = 0 if width < 0
+ string = "width:#{width}%;"
+ if width >= 100
+ string = string + "background-color:#e2383a;"
+ else if width >= 90
+ string = string + "background-color:#e8581f;"
+ string
diff --git a/app/assets/javascripts/application/vendor/chartist.js b/app/assets/javascripts/application/vendor/chartist.js
new file mode 100644
index 0000000..c715450
--- /dev/null
+++ b/app/assets/javascripts/application/vendor/chartist.js
@@ -0,0 +1,4331 @@
+(function (root, factory) {
+ if (typeof define === 'function' && define.amd) {
+ // AMD. Register as an anonymous module unless amdModuleId is set
+ define([], function () {
+ return (root['Chartist'] = factory());
+ });
+ } else if (typeof exports === 'object') {
+ // Node. Does not work with strict CommonJS, but
+ // only CommonJS-like environments that support module.exports,
+ // like Node.
+ module.exports = factory();
+ } else {
+ root['Chartist'] = factory();
+ }
+}(this, function () {
+
+/* Chartist.js 0.9.8
+ * Copyright © 2016 Gion Kunz
+ * Free to use under either the WTFPL license or the MIT license.
+ * https://raw.githubusercontent.com/gionkunz/chartist-js/master/LICENSE-WTFPL
+ * https://raw.githubusercontent.com/gionkunz/chartist-js/master/LICENSE-MIT
+ */
+/**
+ * The core module of Chartist that is mainly providing static functions and higher level functions for chart modules.
+ *
+ * @module Chartist.Core
+ */
+var Chartist = {
+ version: '0.9.8'
+};
+
+(function (window, document, Chartist) {
+ 'use strict';
+
+ /**
+ * This object contains all namespaces used within Chartist.
+ *
+ * @memberof Chartist.Core
+ * @type {{svg: string, xmlns: string, xhtml: string, xlink: string, ct: string}}
+ */
+ Chartist.namespaces = {
+ svg: 'http://www.w3.org/2000/svg',
+ xmlns: 'http://www.w3.org/2000/xmlns/',
+ xhtml: 'http://www.w3.org/1999/xhtml',
+ xlink: 'http://www.w3.org/1999/xlink',
+ ct: 'http://gionkunz.github.com/chartist-js/ct'
+ };
+
+ /**
+ * Helps to simplify functional style code
+ *
+ * @memberof Chartist.Core
+ * @param {*} n This exact value will be returned by the noop function
+ * @return {*} The same value that was provided to the n parameter
+ */
+ Chartist.noop = function (n) {
+ return n;
+ };
+
+ /**
+ * Generates a-z from a number 0 to 26
+ *
+ * @memberof Chartist.Core
+ * @param {Number} n A number from 0 to 26 that will result in a letter a-z
+ * @return {String} A character from a-z based on the input number n
+ */
+ Chartist.alphaNumerate = function (n) {
+ // Limit to a-z
+ return String.fromCharCode(97 + n % 26);
+ };
+
+ /**
+ * Simple recursive object extend
+ *
+ * @memberof Chartist.Core
+ * @param {Object} target Target object where the source will be merged into
+ * @param {Object...} sources This object (objects) will be merged into target and then target is returned
+ * @return {Object} An object that has the same reference as target but is extended and merged with the properties of source
+ */
+ Chartist.extend = function (target) {
+ target = target || {};
+
+ var sources = Array.prototype.slice.call(arguments, 1);
+ sources.forEach(function(source) {
+ for (var prop in source) {
+ if (typeof source[prop] === 'object' && source[prop] !== null && !(source[prop] instanceof Array)) {
+ target[prop] = Chartist.extend({}, target[prop], source[prop]);
+ } else {
+ target[prop] = source[prop];
+ }
+ }
+ });
+
+ return target;
+ };
+
+ /**
+ * Replaces all occurrences of subStr in str with newSubStr and returns a new string.
+ *
+ * @memberof Chartist.Core
+ * @param {String} str
+ * @param {String} subStr
+ * @param {String} newSubStr
+ * @return {String}
+ */
+ Chartist.replaceAll = function(str, subStr, newSubStr) {
+ return str.replace(new RegExp(subStr, 'g'), newSubStr);
+ };
+
+ /**
+ * Converts a number to a string with a unit. If a string is passed then this will be returned unmodified.
+ *
+ * @memberof Chartist.Core
+ * @param {Number} value
+ * @param {String} unit
+ * @return {String} Returns the passed number value with unit.
+ */
+ Chartist.ensureUnit = function(value, unit) {
+ if(typeof value === 'number') {
+ value = value + unit;
+ }
+
+ return value;
+ };
+
+ /**
+ * Converts a number or string to a quantity object.
+ *
+ * @memberof Chartist.Core
+ * @param {String|Number} input
+ * @return {Object} Returns an object containing the value as number and the unit as string.
+ */
+ Chartist.quantity = function(input) {
+ if (typeof input === 'string') {
+ var match = (/^(\d+)\s*(.*)$/g).exec(input);
+ return {
+ value : +match[1],
+ unit: match[2] || undefined
+ };
+ }
+ return { value: input };
+ };
+
+ /**
+ * This is a wrapper around document.querySelector that will return the query if it's already of type Node
+ *
+ * @memberof Chartist.Core
+ * @param {String|Node} query The query to use for selecting a Node or a DOM node that will be returned directly
+ * @return {Node}
+ */
+ Chartist.querySelector = function(query) {
+ return query instanceof Node ? query : document.querySelector(query);
+ };
+
+ /**
+ * Functional style helper to produce array with given length initialized with undefined values
+ *
+ * @memberof Chartist.Core
+ * @param length
+ * @return {Array}
+ */
+ Chartist.times = function(length) {
+ return Array.apply(null, new Array(length));
+ };
+
+ /**
+ * Sum helper to be used in reduce functions
+ *
+ * @memberof Chartist.Core
+ * @param previous
+ * @param current
+ * @return {*}
+ */
+ Chartist.sum = function(previous, current) {
+ return previous + (current ? current : 0);
+ };
+
+ /**
+ * Multiply helper to be used in `Array.map` for multiplying each value of an array with a factor.
+ *
+ * @memberof Chartist.Core
+ * @param {Number} factor
+ * @returns {Function} Function that can be used in `Array.map` to multiply each value in an array
+ */
+ Chartist.mapMultiply = function(factor) {
+ return function(num) {
+ return num * factor;
+ };
+ };
+
+ /**
+ * Add helper to be used in `Array.map` for adding a addend to each value of an array.
+ *
+ * @memberof Chartist.Core
+ * @param {Number} addend
+ * @returns {Function} Function that can be used in `Array.map` to add a addend to each value in an array
+ */
+ Chartist.mapAdd = function(addend) {
+ return function(num) {
+ return num + addend;
+ };
+ };
+
+ /**
+ * Map for multi dimensional arrays where their nested arrays will be mapped in serial. The output array will have the length of the largest nested array. The callback function is called with variable arguments where each argument is the nested array value (or undefined if there are no more values).
+ *
+ * @memberof Chartist.Core
+ * @param arr
+ * @param cb
+ * @return {Array}
+ */
+ Chartist.serialMap = function(arr, cb) {
+ var result = [],
+ length = Math.max.apply(null, arr.map(function(e) {
+ return e.length;
+ }));
+
+ Chartist.times(length).forEach(function(e, index) {
+ var args = arr.map(function(e) {
+ return e[index];
+ });
+
+ result[index] = cb.apply(null, args);
+ });
+
+ return result;
+ };
+
+ /**
+ * This helper function can be used to round values with certain precision level after decimal. This is used to prevent rounding errors near float point precision limit.
+ *
+ * @memberof Chartist.Core
+ * @param {Number} value The value that should be rounded with precision
+ * @param {Number} [digits] The number of digits after decimal used to do the rounding
+ * @returns {number} Rounded value
+ */
+ Chartist.roundWithPrecision = function(value, digits) {
+ var precision = Math.pow(10, digits || Chartist.precision);
+ return Math.round(value * precision) / precision;
+ };
+
+ /**
+ * Precision level used internally in Chartist for rounding. If you require more decimal places you can increase this number.
+ *
+ * @memberof Chartist.Core
+ * @type {number}
+ */
+ Chartist.precision = 8;
+
+ /**
+ * A map with characters to escape for strings to be safely used as attribute values.
+ *
+ * @memberof Chartist.Core
+ * @type {Object}
+ */
+ Chartist.escapingMap = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ '\'': '''
+ };
+
+ /**
+ * This function serializes arbitrary data to a string. In case of data that can't be easily converted to a string, this function will create a wrapper object and serialize the data using JSON.stringify. The outcoming string will always be escaped using Chartist.escapingMap.
+ * If called with null or undefined the function will return immediately with null or undefined.
+ *
+ * @memberof Chartist.Core
+ * @param {Number|String|Object} data
+ * @return {String}
+ */
+ Chartist.serialize = function(data) {
+ if(data === null || data === undefined) {
+ return data;
+ } else if(typeof data === 'number') {
+ data = ''+data;
+ } else if(typeof data === 'object') {
+ data = JSON.stringify({data: data});
+ }
+
+ return Object.keys(Chartist.escapingMap).reduce(function(result, key) {
+ return Chartist.replaceAll(result, key, Chartist.escapingMap[key]);
+ }, data);
+ };
+
+ /**
+ * This function de-serializes a string previously serialized with Chartist.serialize. The string will always be unescaped using Chartist.escapingMap before it's returned. Based on the input value the return type can be Number, String or Object. JSON.parse is used with try / catch to see if the unescaped string can be parsed into an Object and this Object will be returned on success.
+ *
+ * @memberof Chartist.Core
+ * @param {String} data
+ * @return {String|Number|Object}
+ */
+ Chartist.deserialize = function(data) {
+ if(typeof data !== 'string') {
+ return data;
+ }
+
+ data = Object.keys(Chartist.escapingMap).reduce(function(result, key) {
+ return Chartist.replaceAll(result, Chartist.escapingMap[key], key);
+ }, data);
+
+ try {
+ data = JSON.parse(data);
+ data = data.data !== undefined ? data.data : data;
+ } catch(e) {}
+
+ return data;
+ };
+
+ /**
+ * Create or reinitialize the SVG element for the chart
+ *
+ * @memberof Chartist.Core
+ * @param {Node} container The containing DOM Node object that will be used to plant the SVG element
+ * @param {String} width Set the width of the SVG element. Default is 100%
+ * @param {String} height Set the height of the SVG element. Default is 100%
+ * @param {String} className Specify a class to be added to the SVG element
+ * @return {Object} The created/reinitialized SVG element
+ */
+ Chartist.createSvg = function (container, width, height, className) {
+ var svg;
+
+ width = width || '100%';
+ height = height || '100%';
+
+ // Check if there is a previous SVG element in the container that contains the Chartist XML namespace and remove it
+ // Since the DOM API does not support namespaces we need to manually search the returned list http://www.w3.org/TR/selectors-api/
+ Array.prototype.slice.call(container.querySelectorAll('svg')).filter(function filterChartistSvgObjects(svg) {
+ return svg.getAttributeNS(Chartist.namespaces.xmlns, 'ct');
+ }).forEach(function removePreviousElement(svg) {
+ container.removeChild(svg);
+ });
+
+ // Create svg object with width and height or use 100% as default
+ svg = new Chartist.Svg('svg').attr({
+ width: width,
+ height: height
+ }).addClass(className).attr({
+ style: 'width: ' + width + '; height: ' + height + ';'
+ });
+
+ // Add the DOM node to our container
+ container.appendChild(svg._node);
+
+ return svg;
+ };
+
+ /**
+ * Ensures that the data object passed as second argument to the charts is present and correctly initialized.
+ *
+ * @param {Object} data The data object that is passed as second argument to the charts
+ * @return {Object} The normalized data object
+ */
+ Chartist.normalizeData = function(data) {
+ // Ensure data is present otherwise enforce
+ data = data || {series: [], labels: []};
+ data.series = data.series || [];
+ data.labels = data.labels || [];
+
+ // Check if we should generate some labels based on existing series data
+ if (data.series.length > 0 && data.labels.length === 0) {
+ var normalized = Chartist.getDataArray(data),
+ labelCount;
+
+ // If all elements of the normalized data array are arrays we're dealing with
+ // data from Bar or Line charts and we need to find the largest series if they are un-even
+ if (normalized.every(function(value) {
+ return value instanceof Array;
+ })) {
+ // Getting the series with the the most elements
+ labelCount = Math.max.apply(null, normalized.map(function(series) {
+ return series.length;
+ }));
+ } else {
+ // We're dealing with Pie data so we just take the normalized array length
+ labelCount = normalized.length;
+ }
+
+ // Setting labels to an array with emptry strings using our labelCount estimated above
+ data.labels = Chartist.times(labelCount).map(function() {
+ return '';
+ });
+ }
+ return data;
+ };
+
+ /**
+ * Reverses the series, labels and series data arrays.
+ *
+ * @memberof Chartist.Core
+ * @param data
+ */
+ Chartist.reverseData = function(data) {
+ data.labels.reverse();
+ data.series.reverse();
+ for (var i = 0; i < data.series.length; i++) {
+ if(typeof(data.series[i]) === 'object' && data.series[i].data !== undefined) {
+ data.series[i].data.reverse();
+ } else if(data.series[i] instanceof Array) {
+ data.series[i].reverse();
+ }
+ }
+ };
+
+ /**
+ * Convert data series into plain array
+ *
+ * @memberof Chartist.Core
+ * @param {Object} data The series object that contains the data to be visualized in the chart
+ * @param {Boolean} reverse If true the whole data is reversed by the getDataArray call. This will modify the data object passed as first parameter. The labels as well as the series order is reversed. The whole series data arrays are reversed too.
+ * @param {Boolean} multi Create a multi dimensional array from a series data array where a value object with `x` and `y` values will be created.
+ * @return {Array} A plain array that contains the data to be visualized in the chart
+ */
+ Chartist.getDataArray = function (data, reverse, multi) {
+ // If the data should be reversed but isn't we need to reverse it
+ // If it's reversed but it shouldn't we need to reverse it back
+ // That's required to handle data updates correctly and to reflect the responsive configurations
+ if(reverse && !data.reversed || !reverse && data.reversed) {
+ Chartist.reverseData(data);
+ data.reversed = !data.reversed;
+ }
+
+ // Recursively walks through nested arrays and convert string values to numbers and objects with value properties
+ // to values. Check the tests in data core -> data normalization for a detailed specification of expected values
+ function recursiveConvert(value) {
+ if(Chartist.isFalseyButZero(value)) {
+ // This is a hole in data and we should return undefined
+ return undefined;
+ } else if((value.data || value) instanceof Array) {
+ return (value.data || value).map(recursiveConvert);
+ } else if(value.hasOwnProperty('value')) {
+ return recursiveConvert(value.value);
+ } else {
+ if(multi) {
+ var multiValue = {};
+
+ // Single series value arrays are assumed to specify the Y-Axis value
+ // For example: [1, 2] => [{x: undefined, y: 1}, {x: undefined, y: 2}]
+ // If multi is a string then it's assumed that it specified which dimension should be filled as default
+ if(typeof multi === 'string') {
+ multiValue[multi] = Chartist.getNumberOrUndefined(value);
+ } else {
+ multiValue.y = Chartist.getNumberOrUndefined(value);
+ }
+
+ multiValue.x = value.hasOwnProperty('x') ? Chartist.getNumberOrUndefined(value.x) : multiValue.x;
+ multiValue.y = value.hasOwnProperty('y') ? Chartist.getNumberOrUndefined(value.y) : multiValue.y;
+
+ return multiValue;
+
+ } else {
+ return Chartist.getNumberOrUndefined(value);
+ }
+ }
+ }
+
+ return data.series.map(recursiveConvert);
+ };
+
+ /**
+ * Converts a number into a padding object.
+ *
+ * @memberof Chartist.Core
+ * @param {Object|Number} padding
+ * @param {Number} [fallback] This value is used to fill missing values if a incomplete padding object was passed
+ * @returns {Object} Returns a padding object containing top, right, bottom, left properties filled with the padding number passed in as argument. If the argument is something else than a number (presumably already a correct padding object) then this argument is directly returned.
+ */
+ Chartist.normalizePadding = function(padding, fallback) {
+ fallback = fallback || 0;
+
+ return typeof padding === 'number' ? {
+ top: padding,
+ right: padding,
+ bottom: padding,
+ left: padding
+ } : {
+ top: typeof padding.top === 'number' ? padding.top : fallback,
+ right: typeof padding.right === 'number' ? padding.right : fallback,
+ bottom: typeof padding.bottom === 'number' ? padding.bottom : fallback,
+ left: typeof padding.left === 'number' ? padding.left : fallback
+ };
+ };
+
+ Chartist.getMetaData = function(series, index) {
+ var value = series.data ? series.data[index] : series[index];
+ return value ? Chartist.serialize(value.meta) : undefined;
+ };
+
+ /**
+ * Calculate the order of magnitude for the chart scale
+ *
+ * @memberof Chartist.Core
+ * @param {Number} value The value Range of the chart
+ * @return {Number} The order of magnitude
+ */
+ Chartist.orderOfMagnitude = function (value) {
+ return Math.floor(Math.log(Math.abs(value)) / Math.LN10);
+ };
+
+ /**
+ * Project a data length into screen coordinates (pixels)
+ *
+ * @memberof Chartist.Core
+ * @param {Object} axisLength The svg element for the chart
+ * @param {Number} length Single data value from a series array
+ * @param {Object} bounds All the values to set the bounds of the chart
+ * @return {Number} The projected data length in pixels
+ */
+ Chartist.projectLength = function (axisLength, length, bounds) {
+ return length / bounds.range * axisLength;
+ };
+
+ /**
+ * Get the height of the area in the chart for the data series
+ *
+ * @memberof Chartist.Core
+ * @param {Object} svg The svg element for the chart
+ * @param {Object} options The Object that contains all the optional values for the chart
+ * @return {Number} The height of the area in the chart for the data series
+ */
+ Chartist.getAvailableHeight = function (svg, options) {
+ return Math.max((Chartist.quantity(options.height).value || svg.height()) - (options.chartPadding.top + options.chartPadding.bottom) - options.axisX.offset, 0);
+ };
+
+ /**
+ * Get highest and lowest value of data array. This Array contains the data that will be visualized in the chart.
+ *
+ * @memberof Chartist.Core
+ * @param {Array} data The array that contains the data to be visualized in the chart
+ * @param {Object} options The Object that contains the chart options
+ * @param {String} dimension Axis dimension 'x' or 'y' used to access the correct value and high / low configuration
+ * @return {Object} An object that contains the highest and lowest value that will be visualized on the chart.
+ */
+ Chartist.getHighLow = function (data, options, dimension) {
+ // TODO: Remove workaround for deprecated global high / low config. Axis high / low configuration is preferred
+ options = Chartist.extend({}, options, dimension ? options['axis' + dimension.toUpperCase()] : {});
+
+ var highLow = {
+ high: options.high === undefined ? -Number.MAX_VALUE : +options.high,
+ low: options.low === undefined ? Number.MAX_VALUE : +options.low
+ };
+ var findHigh = options.high === undefined;
+ var findLow = options.low === undefined;
+
+ // Function to recursively walk through arrays and find highest and lowest number
+ function recursiveHighLow(data) {
+ if(data === undefined) {
+ return undefined;
+ } else if(data instanceof Array) {
+ for (var i = 0; i < data.length; i++) {
+ recursiveHighLow(data[i]);
+ }
+ } else {
+ var value = dimension ? +data[dimension] : +data;
+
+ if (findHigh && value > highLow.high) {
+ highLow.high = value;
+ }
+
+ if (findLow && value < highLow.low) {
+ highLow.low = value;
+ }
+ }
+ }
+
+ // Start to find highest and lowest number recursively
+ if(findHigh || findLow) {
+ recursiveHighLow(data);
+ }
+
+ // Overrides of high / low based on reference value, it will make sure that the invisible reference value is
+ // used to generate the chart. This is useful when the chart always needs to contain the position of the
+ // invisible reference value in the view i.e. for bipolar scales.
+ if (options.referenceValue || options.referenceValue === 0) {
+ highLow.high = Math.max(options.referenceValue, highLow.high);
+ highLow.low = Math.min(options.referenceValue, highLow.low);
+ }
+
+ // If high and low are the same because of misconfiguration or flat data (only the same value) we need
+ // to set the high or low to 0 depending on the polarity
+ if (highLow.high <= highLow.low) {
+ // If both values are 0 we set high to 1
+ if (highLow.low === 0) {
+ highLow.high = 1;
+ } else if (highLow.low < 0) {
+ // If we have the same negative value for the bounds we set bounds.high to 0
+ highLow.high = 0;
+ } else if (highLow.high > 0) {
+ // If we have the same positive value for the bounds we set bounds.low to 0
+ highLow.low = 0;
+ } else {
+ // If data array was empty, values are Number.MAX_VALUE and -Number.MAX_VALUE. Set bounds to prevent errors
+ highLow.high = 1;
+ highLow.low = 0;
+ }
+ }
+
+ return highLow;
+ };
+
+ /**
+ * Checks if the value is a valid number or string with a number.
+ *
+ * @memberof Chartist.Core
+ * @param value
+ * @returns {Boolean}
+ */
+ Chartist.isNum = function(value) {
+ return !isNaN(value) && isFinite(value);
+ };
+
+ /**
+ * Returns true on all falsey values except the numeric value 0.
+ *
+ * @memberof Chartist.Core
+ * @param value
+ * @returns {boolean}
+ */
+ Chartist.isFalseyButZero = function(value) {
+ return !value && value !== 0;
+ };
+
+ /**
+ * Returns a number if the passed parameter is a valid number or the function will return undefined. On all other values than a valid number, this function will return undefined.
+ *
+ * @memberof Chartist.Core
+ * @param value
+ * @returns {*}
+ */
+ Chartist.getNumberOrUndefined = function(value) {
+ return isNaN(+value) ? undefined : +value;
+ };
+
+ /**
+ * Gets a value from a dimension `value.x` or `value.y` while returning value directly if it's a valid numeric value. If the value is not numeric and it's falsey this function will return undefined.
+ *
+ * @param value
+ * @param dimension
+ * @returns {*}
+ */
+ Chartist.getMultiValue = function(value, dimension) {
+ if(Chartist.isNum(value)) {
+ return +value;
+ } else if(value) {
+ return value[dimension || 'y'] || 0;
+ } else {
+ return 0;
+ }
+ };
+
+ /**
+ * Pollard Rho Algorithm to find smallest factor of an integer value. There are more efficient algorithms for factorization, but this one is quite efficient and not so complex.
+ *
+ * @memberof Chartist.Core
+ * @param {Number} num An integer number where the smallest factor should be searched for
+ * @returns {Number} The smallest integer factor of the parameter num.
+ */
+ Chartist.rho = function(num) {
+ if(num === 1) {
+ return num;
+ }
+
+ function gcd(p, q) {
+ if (p % q === 0) {
+ return q;
+ } else {
+ return gcd(q, p % q);
+ }
+ }
+
+ function f(x) {
+ return x * x + 1;
+ }
+
+ var x1 = 2, x2 = 2, divisor;
+ if (num % 2 === 0) {
+ return 2;
+ }
+
+ do {
+ x1 = f(x1) % num;
+ x2 = f(f(x2)) % num;
+ divisor = gcd(Math.abs(x1 - x2), num);
+ } while (divisor === 1);
+
+ return divisor;
+ };
+
+ /**
+ * Calculate and retrieve all the bounds for the chart and return them in one array
+ *
+ * @memberof Chartist.Core
+ * @param {Number} axisLength The length of the Axis used for
+ * @param {Object} highLow An object containing a high and low property indicating the value range of the chart.
+ * @param {Number} scaleMinSpace The minimum projected length a step should result in
+ * @param {Boolean} onlyInteger
+ * @return {Object} All the values to set the bounds of the chart
+ */
+ Chartist.getBounds = function (axisLength, highLow, scaleMinSpace, onlyInteger) {
+ var i,
+ optimizationCounter = 0,
+ newMin,
+ newMax,
+ bounds = {
+ high: highLow.high,
+ low: highLow.low
+ };
+
+ bounds.valueRange = bounds.high - bounds.low;
+ bounds.oom = Chartist.orderOfMagnitude(bounds.valueRange);
+ bounds.step = Math.pow(10, bounds.oom);
+ bounds.min = Math.floor(bounds.low / bounds.step) * bounds.step;
+ bounds.max = Math.ceil(bounds.high / bounds.step) * bounds.step;
+ bounds.range = bounds.max - bounds.min;
+ bounds.numberOfSteps = Math.round(bounds.range / bounds.step);
+
+ // Optimize scale step by checking if subdivision is possible based on horizontalGridMinSpace
+ // If we are already below the scaleMinSpace value we will scale up
+ var length = Chartist.projectLength(axisLength, bounds.step, bounds);
+ var scaleUp = length < scaleMinSpace;
+ var smallestFactor = onlyInteger ? Chartist.rho(bounds.range) : 0;
+
+ // First check if we should only use integer steps and if step 1 is still larger than scaleMinSpace so we can use 1
+ if(onlyInteger && Chartist.projectLength(axisLength, 1, bounds) >= scaleMinSpace) {
+ bounds.step = 1;
+ } else if(onlyInteger && smallestFactor < bounds.step && Chartist.projectLength(axisLength, smallestFactor, bounds) >= scaleMinSpace) {
+ // If step 1 was too small, we can try the smallest factor of range
+ // If the smallest factor is smaller than the current bounds.step and the projected length of smallest factor
+ // is larger than the scaleMinSpace we should go for it.
+ bounds.step = smallestFactor;
+ } else {
+ // Trying to divide or multiply by 2 and find the best step value
+ while (true) {
+ if (scaleUp && Chartist.projectLength(axisLength, bounds.step, bounds) <= scaleMinSpace) {
+ bounds.step *= 2;
+ } else if (!scaleUp && Chartist.projectLength(axisLength, bounds.step / 2, bounds) >= scaleMinSpace) {
+ bounds.step /= 2;
+ if(onlyInteger && bounds.step % 1 !== 0) {
+ bounds.step *= 2;
+ break;
+ }
+ } else {
+ break;
+ }
+
+ if(optimizationCounter++ > 1000) {
+ throw new Error('Exceeded maximum number of iterations while optimizing scale step!');
+ }
+ }
+ }
+
+ // step must not be less than EPSILON to create values that can be represented as floating number.
+ var EPSILON = 2.221E-16;
+ bounds.step = Math.max(bounds.step, EPSILON);
+
+ // Narrow min and max based on new step
+ newMin = bounds.min;
+ newMax = bounds.max;
+ while(newMin + bounds.step <= bounds.low) {
+ newMin += bounds.step;
+ }
+ while(newMax - bounds.step >= bounds.high) {
+ newMax -= bounds.step;
+ }
+ bounds.min = newMin;
+ bounds.max = newMax;
+ bounds.range = bounds.max - bounds.min;
+
+ var values = [];
+ for (i = bounds.min; i <= bounds.max; i += bounds.step) {
+ var value = Chartist.roundWithPrecision(i);
+ if (value !== values[values.length - 1]) {
+ values.push(i);
+ }
+ }
+ bounds.values = values;
+ return bounds;
+ };
+
+ /**
+ * Calculate cartesian coordinates of polar coordinates
+ *
+ * @memberof Chartist.Core
+ * @param {Number} centerX X-axis coordinates of center point of circle segment
+ * @param {Number} centerY X-axis coordinates of center point of circle segment
+ * @param {Number} radius Radius of circle segment
+ * @param {Number} angleInDegrees Angle of circle segment in degrees
+ * @return {{x:Number, y:Number}} Coordinates of point on circumference
+ */
+ Chartist.polarToCartesian = function (centerX, centerY, radius, angleInDegrees) {
+ var angleInRadians = (angleInDegrees - 90) * Math.PI / 180.0;
+
+ return {
+ x: centerX + (radius * Math.cos(angleInRadians)),
+ y: centerY + (radius * Math.sin(angleInRadians))
+ };
+ };
+
+ /**
+ * Initialize chart drawing rectangle (area where chart is drawn) x1,y1 = bottom left / x2,y2 = top right
+ *
+ * @memberof Chartist.Core
+ * @param {Object} svg The svg element for the chart
+ * @param {Object} options The Object that contains all the optional values for the chart
+ * @param {Number} [fallbackPadding] The fallback padding if partial padding objects are used
+ * @return {Object} The chart rectangles coordinates inside the svg element plus the rectangles measurements
+ */
+ Chartist.createChartRect = function (svg, options, fallbackPadding) {
+ var hasAxis = !!(options.axisX || options.axisY);
+ var yAxisOffset = hasAxis ? options.axisY.offset : 0;
+ var xAxisOffset = hasAxis ? options.axisX.offset : 0;
+ // If width or height results in invalid value (including 0) we fallback to the unitless settings or even 0
+ var width = svg.width() || Chartist.quantity(options.width).value || 0;
+ var height = svg.height() || Chartist.quantity(options.height).value || 0;
+ var normalizedPadding = Chartist.normalizePadding(options.chartPadding, fallbackPadding);
+
+ // If settings were to small to cope with offset (legacy) and padding, we'll adjust
+ width = Math.max(width, yAxisOffset + normalizedPadding.left + normalizedPadding.right);
+ height = Math.max(height, xAxisOffset + normalizedPadding.top + normalizedPadding.bottom);
+
+ var chartRect = {
+ padding: normalizedPadding,
+ width: function () {
+ return this.x2 - this.x1;
+ },
+ height: function () {
+ return this.y1 - this.y2;
+ }
+ };
+
+ if(hasAxis) {
+ if (options.axisX.position === 'start') {
+ chartRect.y2 = normalizedPadding.top + xAxisOffset;
+ chartRect.y1 = Math.max(height - normalizedPadding.bottom, chartRect.y2 + 1);
+ } else {
+ chartRect.y2 = normalizedPadding.top;
+ chartRect.y1 = Math.max(height - normalizedPadding.bottom - xAxisOffset, chartRect.y2 + 1);
+ }
+
+ if (options.axisY.position === 'start') {
+ chartRect.x1 = normalizedPadding.left + yAxisOffset;
+ chartRect.x2 = Math.max(width - normalizedPadding.right, chartRect.x1 + 1);
+ } else {
+ chartRect.x1 = normalizedPadding.left;
+ chartRect.x2 = Math.max(width - normalizedPadding.right - yAxisOffset, chartRect.x1 + 1);
+ }
+ } else {
+ chartRect.x1 = normalizedPadding.left;
+ chartRect.x2 = Math.max(width - normalizedPadding.right, chartRect.x1 + 1);
+ chartRect.y2 = normalizedPadding.top;
+ chartRect.y1 = Math.max(height - normalizedPadding.bottom, chartRect.y2 + 1);
+ }
+
+ return chartRect;
+ };
+
+ /**
+ * Creates a grid line based on a projected value.
+ *
+ * @memberof Chartist.Core
+ * @param position
+ * @param index
+ * @param axis
+ * @param offset
+ * @param length
+ * @param group
+ * @param classes
+ * @param eventEmitter
+ */
+ Chartist.createGrid = function(position, index, axis, offset, length, group, classes, eventEmitter) {
+ var positionalData = {};
+ positionalData[axis.units.pos + '1'] = position;
+ positionalData[axis.units.pos + '2'] = position;
+ positionalData[axis.counterUnits.pos + '1'] = offset;
+ positionalData[axis.counterUnits.pos + '2'] = offset + length;
+
+ var gridElement = group.elem('line', positionalData, classes.join(' '));
+
+ // Event for grid draw
+ eventEmitter.emit('draw',
+ Chartist.extend({
+ type: 'grid',
+ axis: axis,
+ index: index,
+ group: group,
+ element: gridElement
+ }, positionalData)
+ );
+ };
+
+ /**
+ * Creates a label based on a projected value and an axis.
+ *
+ * @memberof Chartist.Core
+ * @param position
+ * @param length
+ * @param index
+ * @param labels
+ * @param axis
+ * @param axisOffset
+ * @param labelOffset
+ * @param group
+ * @param classes
+ * @param useForeignObject
+ * @param eventEmitter
+ */
+ Chartist.createLabel = function(position, length, index, labels, axis, axisOffset, labelOffset, group, classes, useForeignObject, eventEmitter) {
+ var labelElement;
+ var positionalData = {};
+
+ positionalData[axis.units.pos] = position + labelOffset[axis.units.pos];
+ positionalData[axis.counterUnits.pos] = labelOffset[axis.counterUnits.pos];
+ positionalData[axis.units.len] = length;
+ positionalData[axis.counterUnits.len] = Math.max(0, axisOffset - 10);
+
+ if(useForeignObject) {
+ // We need to set width and height explicitly to px as span will not expand with width and height being
+ // 100% in all browsers
+ var content = '
' +
+ labels[index] + '';
+
+ labelElement = group.foreignObject(content, Chartist.extend({
+ style: 'overflow: visible;'
+ }, positionalData));
+ } else {
+ labelElement = group.elem('text', positionalData, classes.join(' ')).text(labels[index]);
+ }
+
+ eventEmitter.emit('draw', Chartist.extend({
+ type: 'label',
+ axis: axis,
+ index: index,
+ group: group,
+ element: labelElement,
+ text: labels[index]
+ }, positionalData));
+ };
+
+ /**
+ * Helper to read series specific options from options object. It automatically falls back to the global option if
+ * there is no option in the series options.
+ *
+ * @param {Object} series Series object
+ * @param {Object} options Chartist options object
+ * @param {string} key The options key that should be used to obtain the options
+ * @returns {*}
+ */
+ Chartist.getSeriesOption = function(series, options, key) {
+ if(series.name && options.series && options.series[series.name]) {
+ var seriesOptions = options.series[series.name];
+ return seriesOptions.hasOwnProperty(key) ? seriesOptions[key] : options[key];
+ } else {
+ return options[key];
+ }
+ };
+
+ /**
+ * Provides options handling functionality with callback for options changes triggered by responsive options and media query matches
+ *
+ * @memberof Chartist.Core
+ * @param {Object} options Options set by user
+ * @param {Array} responsiveOptions Optional functions to add responsive behavior to chart
+ * @param {Object} eventEmitter The event emitter that will be used to emit the options changed events
+ * @return {Object} The consolidated options object from the defaults, base and matching responsive options
+ */
+ Chartist.optionsProvider = function (options, responsiveOptions, eventEmitter) {
+ var baseOptions = Chartist.extend({}, options),
+ currentOptions,
+ mediaQueryListeners = [],
+ i;
+
+ function updateCurrentOptions(mediaEvent) {
+ var previousOptions = currentOptions;
+ currentOptions = Chartist.extend({}, baseOptions);
+
+ if (responsiveOptions) {
+ for (i = 0; i < responsiveOptions.length; i++) {
+ var mql = window.matchMedia(responsiveOptions[i][0]);
+ if (mql.matches) {
+ currentOptions = Chartist.extend(currentOptions, responsiveOptions[i][1]);
+ }
+ }
+ }
+
+ if(eventEmitter && mediaEvent) {
+ eventEmitter.emit('optionsChanged', {
+ previousOptions: previousOptions,
+ currentOptions: currentOptions
+ });
+ }
+ }
+
+ function removeMediaQueryListeners() {
+ mediaQueryListeners.forEach(function(mql) {
+ mql.removeListener(updateCurrentOptions);
+ });
+ }
+
+ if (!window.matchMedia) {
+ throw 'window.matchMedia not found! Make sure you\'re using a polyfill.';
+ } else if (responsiveOptions) {
+
+ for (i = 0; i < responsiveOptions.length; i++) {
+ var mql = window.matchMedia(responsiveOptions[i][0]);
+ mql.addListener(updateCurrentOptions);
+ mediaQueryListeners.push(mql);
+ }
+ }
+ // Execute initially without an event argument so we get the correct options
+ updateCurrentOptions();
+
+ return {
+ removeMediaQueryListeners: removeMediaQueryListeners,
+ getCurrentOptions: function getCurrentOptions() {
+ return Chartist.extend({}, currentOptions);
+ }
+ };
+ };
+
+
+ /**
+ * Splits a list of coordinates and associated values into segments. Each returned segment contains a pathCoordinates
+ * valueData property describing the segment.
+ *
+ * With the default options, segments consist of contiguous sets of points that do not have an undefined value. Any
+ * points with undefined values are discarded.
+ *
+ * **Options**
+ * The following options are used to determine how segments are formed
+ * ```javascript
+ * var options = {
+ * // If fillHoles is true, undefined values are simply discarded without creating a new segment. Assuming other options are default, this returns single segment.
+ * fillHoles: false,
+ * // If increasingX is true, the coordinates in all segments have strictly increasing x-values.
+ * increasingX: false
+ * };
+ * ```
+ *
+ * @memberof Chartist.Core
+ * @param {Array} pathCoordinates List of point coordinates to be split in the form [x1, y1, x2, y2 ... xn, yn]
+ * @param {Array} values List of associated point values in the form [v1, v2 .. vn]
+ * @param {Object} options Options set by user
+ * @return {Array} List of segments, each containing a pathCoordinates and valueData property.
+ */
+ Chartist.splitIntoSegments = function(pathCoordinates, valueData, options) {
+ var defaultOptions = {
+ increasingX: false,
+ fillHoles: false
+ };
+
+ options = Chartist.extend({}, defaultOptions, options);
+
+ var segments = [];
+ var hole = true;
+
+ for(var i = 0; i < pathCoordinates.length; i += 2) {
+ // If this value is a "hole" we set the hole flag
+ if(valueData[i / 2].value === undefined) {
+ if(!options.fillHoles) {
+ hole = true;
+ }
+ } else {
+ if(options.increasingX && i >= 2 && pathCoordinates[i] <= pathCoordinates[i-2]) {
+ // X is not increasing, so we need to make sure we start a new segment
+ hole = true;
+ }
+
+
+ // If it's a valid value we need to check if we're coming out of a hole and create a new empty segment
+ if(hole) {
+ segments.push({
+ pathCoordinates: [],
+ valueData: []
+ });
+ // As we have a valid value now, we are not in a "hole" anymore
+ hole = false;
+ }
+
+ // Add to the segment pathCoordinates and valueData
+ segments[segments.length - 1].pathCoordinates.push(pathCoordinates[i], pathCoordinates[i + 1]);
+ segments[segments.length - 1].valueData.push(valueData[i / 2]);
+ }
+ }
+
+ return segments;
+ };
+}(window, document, Chartist));
+;/**
+ * Chartist path interpolation functions.
+ *
+ * @module Chartist.Interpolation
+ */
+/* global Chartist */
+(function(window, document, Chartist) {
+ 'use strict';
+
+ Chartist.Interpolation = {};
+
+ /**
+ * This interpolation function does not smooth the path and the result is only containing lines and no curves.
+ *
+ * @example
+ * var chart = new Chartist.Line('.ct-chart', {
+ * labels: [1, 2, 3, 4, 5],
+ * series: [[1, 2, 8, 1, 7]]
+ * }, {
+ * lineSmooth: Chartist.Interpolation.none({
+ * fillHoles: false
+ * })
+ * });
+ *
+ *
+ * @memberof Chartist.Interpolation
+ * @return {Function}
+ */
+ Chartist.Interpolation.none = function(options) {
+ var defaultOptions = {
+ fillHoles: false
+ };
+ options = Chartist.extend({}, defaultOptions, options);
+ return function none(pathCoordinates, valueData) {
+ var path = new Chartist.Svg.Path();
+ var hole = true;
+
+ for(var i = 0; i < pathCoordinates.length; i += 2) {
+ var currX = pathCoordinates[i];
+ var currY = pathCoordinates[i + 1];
+ var currData = valueData[i / 2];
+
+ if(currData.value !== undefined) {
+
+ if(hole) {
+ path.move(currX, currY, false, currData);
+ } else {
+ path.line(currX, currY, false, currData);
+ }
+
+ hole = false;
+ } else if(!options.fillHoles) {
+ hole = true;
+ }
+ }
+
+ return path;
+ };
+ };
+
+ /**
+ * Simple smoothing creates horizontal handles that are positioned with a fraction of the length between two data points. You can use the divisor option to specify the amount of smoothing.
+ *
+ * Simple smoothing can be used instead of `Chartist.Smoothing.cardinal` if you'd like to get rid of the artifacts it produces sometimes. Simple smoothing produces less flowing lines but is accurate by hitting the points and it also doesn't swing below or above the given data point.
+ *
+ * All smoothing functions within Chartist are factory functions that accept an options parameter. The simple interpolation function accepts one configuration parameter `divisor`, between 1 and ∞, which controls the smoothing characteristics.
+ *
+ * @example
+ * var chart = new Chartist.Line('.ct-chart', {
+ * labels: [1, 2, 3, 4, 5],
+ * series: [[1, 2, 8, 1, 7]]
+ * }, {
+ * lineSmooth: Chartist.Interpolation.simple({
+ * divisor: 2,
+ * fillHoles: false
+ * })
+ * });
+ *
+ *
+ * @memberof Chartist.Interpolation
+ * @param {Object} options The options of the simple interpolation factory function.
+ * @return {Function}
+ */
+ Chartist.Interpolation.simple = function(options) {
+ var defaultOptions = {
+ divisor: 2,
+ fillHoles: false
+ };
+ options = Chartist.extend({}, defaultOptions, options);
+
+ var d = 1 / Math.max(1, options.divisor);
+
+ return function simple(pathCoordinates, valueData) {
+ var path = new Chartist.Svg.Path();
+ var prevX, prevY, prevData;
+
+ for(var i = 0; i < pathCoordinates.length; i += 2) {
+ var currX = pathCoordinates[i];
+ var currY = pathCoordinates[i + 1];
+ var length = (currX - prevX) * d;
+ var currData = valueData[i / 2];
+
+ if(currData.value !== undefined) {
+
+ if(prevData === undefined) {
+ path.move(currX, currY, false, currData);
+ } else {
+ path.curve(
+ prevX + length,
+ prevY,
+ currX - length,
+ currY,
+ currX,
+ currY,
+ false,
+ currData
+ );
+ }
+
+ prevX = currX;
+ prevY = currY;
+ prevData = currData;
+ } else if(!options.fillHoles) {
+ prevX = currX = prevData = undefined;
+ }
+ }
+
+ return path;
+ };
+ };
+
+ /**
+ * Cardinal / Catmull-Rome spline interpolation is the default smoothing function in Chartist. It produces nice results where the splines will always meet the points. It produces some artifacts though when data values are increased or decreased rapidly. The line may not follow a very accurate path and if the line should be accurate this smoothing function does not produce the best results.
+ *
+ * Cardinal splines can only be created if there are more than two data points. If this is not the case this smoothing will fallback to `Chartist.Smoothing.none`.
+ *
+ * All smoothing functions within Chartist are factory functions that accept an options parameter. The cardinal interpolation function accepts one configuration parameter `tension`, between 0 and 1, which controls the smoothing intensity.
+ *
+ * @example
+ * var chart = new Chartist.Line('.ct-chart', {
+ * labels: [1, 2, 3, 4, 5],
+ * series: [[1, 2, 8, 1, 7]]
+ * }, {
+ * lineSmooth: Chartist.Interpolation.cardinal({
+ * tension: 1,
+ * fillHoles: false
+ * })
+ * });
+ *
+ * @memberof Chartist.Interpolation
+ * @param {Object} options The options of the cardinal factory function.
+ * @return {Function}
+ */
+ Chartist.Interpolation.cardinal = function(options) {
+ var defaultOptions = {
+ tension: 1,
+ fillHoles: false
+ };
+
+ options = Chartist.extend({}, defaultOptions, options);
+
+ var t = Math.min(1, Math.max(0, options.tension)),
+ c = 1 - t;
+
+ return function cardinal(pathCoordinates, valueData) {
+ // First we try to split the coordinates into segments
+ // This is necessary to treat "holes" in line charts
+ var segments = Chartist.splitIntoSegments(pathCoordinates, valueData, {
+ fillHoles: options.fillHoles
+ });
+
+ if(!segments.length) {
+ // If there were no segments return 'Chartist.Interpolation.none'
+ return Chartist.Interpolation.none()([]);
+ } else if(segments.length > 1) {
+ // If the split resulted in more that one segment we need to interpolate each segment individually and join them
+ // afterwards together into a single path.
+ var paths = [];
+ // For each segment we will recurse the cardinal function
+ segments.forEach(function(segment) {
+ paths.push(cardinal(segment.pathCoordinates, segment.valueData));
+ });
+ // Join the segment path data into a single path and return
+ return Chartist.Svg.Path.join(paths);
+ } else {
+ // If there was only one segment we can proceed regularly by using pathCoordinates and valueData from the first
+ // segment
+ pathCoordinates = segments[0].pathCoordinates;
+ valueData = segments[0].valueData;
+
+ // If less than two points we need to fallback to no smoothing
+ if(pathCoordinates.length <= 4) {
+ return Chartist.Interpolation.none()(pathCoordinates, valueData);
+ }
+
+ var path = new Chartist.Svg.Path().move(pathCoordinates[0], pathCoordinates[1], false, valueData[0]),
+ z;
+
+ for (var i = 0, iLen = pathCoordinates.length; iLen - 2 * !z > i; i += 2) {
+ var p = [
+ {x: +pathCoordinates[i - 2], y: +pathCoordinates[i - 1]},
+ {x: +pathCoordinates[i], y: +pathCoordinates[i + 1]},
+ {x: +pathCoordinates[i + 2], y: +pathCoordinates[i + 3]},
+ {x: +pathCoordinates[i + 4], y: +pathCoordinates[i + 5]}
+ ];
+ if (z) {
+ if (!i) {
+ p[0] = {x: +pathCoordinates[iLen - 2], y: +pathCoordinates[iLen - 1]};
+ } else if (iLen - 4 === i) {
+ p[3] = {x: +pathCoordinates[0], y: +pathCoordinates[1]};
+ } else if (iLen - 2 === i) {
+ p[2] = {x: +pathCoordinates[0], y: +pathCoordinates[1]};
+ p[3] = {x: +pathCoordinates[2], y: +pathCoordinates[3]};
+ }
+ } else {
+ if (iLen - 4 === i) {
+ p[3] = p[2];
+ } else if (!i) {
+ p[0] = {x: +pathCoordinates[i], y: +pathCoordinates[i + 1]};
+ }
+ }
+
+ path.curve(
+ (t * (-p[0].x + 6 * p[1].x + p[2].x) / 6) + (c * p[2].x),
+ (t * (-p[0].y + 6 * p[1].y + p[2].y) / 6) + (c * p[2].y),
+ (t * (p[1].x + 6 * p[2].x - p[3].x) / 6) + (c * p[2].x),
+ (t * (p[1].y + 6 * p[2].y - p[3].y) / 6) + (c * p[2].y),
+ p[2].x,
+ p[2].y,
+ false,
+ valueData[(i + 2) / 2]
+ );
+ }
+
+ return path;
+ }
+ };
+ };
+
+ /**
+ * Monotone Cubic spline interpolation produces a smooth curve which preserves monotonicity. Unlike cardinal splines, the curve will not extend beyond the range of y-values of the original data points.
+ *
+ * Monotone Cubic splines can only be created if there are more than two data points. If this is not the case this smoothing will fallback to `Chartist.Smoothing.none`.
+ *
+ * The x-values of subsequent points must be increasing to fit a Monotone Cubic spline. If this condition is not met for a pair of adjacent points, then there will be a break in the curve between those data points.
+ *
+ * All smoothing functions within Chartist are factory functions that accept an options parameter.
+ *
+ * @example
+ * var chart = new Chartist.Line('.ct-chart', {
+ * labels: [1, 2, 3, 4, 5],
+ * series: [[1, 2, 8, 1, 7]]
+ * }, {
+ * lineSmooth: Chartist.Interpolation.monotoneCubic({
+ * fillHoles: false
+ * })
+ * });
+ *
+ * @memberof Chartist.Interpolation
+ * @param {Object} options The options of the monotoneCubic factory function.
+ * @return {Function}
+ */
+ Chartist.Interpolation.monotoneCubic = function(options) {
+ var defaultOptions = {
+ fillHoles: false
+ };
+
+ options = Chartist.extend({}, defaultOptions, options);
+
+ return function monotoneCubic(pathCoordinates, valueData) {
+ // First we try to split the coordinates into segments
+ // This is necessary to treat "holes" in line charts
+ var segments = Chartist.splitIntoSegments(pathCoordinates, valueData, {
+ fillHoles: options.fillHoles,
+ increasingX: true
+ });
+
+ if(!segments.length) {
+ // If there were no segments return 'Chartist.Interpolation.none'
+ return Chartist.Interpolation.none()([]);
+ } else if(segments.length > 1) {
+ // If the split resulted in more that one segment we need to interpolate each segment individually and join them
+ // afterwards together into a single path.
+ var paths = [];
+ // For each segment we will recurse the monotoneCubic fn function
+ segments.forEach(function(segment) {
+ paths.push(monotoneCubic(segment.pathCoordinates, segment.valueData));
+ });
+ // Join the segment path data into a single path and return
+ return Chartist.Svg.Path.join(paths);
+ } else {
+ // If there was only one segment we can proceed regularly by using pathCoordinates and valueData from the first
+ // segment
+ pathCoordinates = segments[0].pathCoordinates;
+ valueData = segments[0].valueData;
+
+ // If less than three points we need to fallback to no smoothing
+ if(pathCoordinates.length <= 4) {
+ return Chartist.Interpolation.none()(pathCoordinates, valueData);
+ }
+
+ var xs = [],
+ ys = [],
+ i,
+ n = pathCoordinates.length / 2,
+ ms = [],
+ ds = [], dys = [], dxs = [],
+ path;
+
+ // Populate x and y coordinates into separate arrays, for readability
+
+ for(i = 0; i < n; i++) {
+ xs[i] = pathCoordinates[i * 2];
+ ys[i] = pathCoordinates[i * 2 + 1];
+ }
+
+ // Calculate deltas and derivative
+
+ for(i = 0; i < n - 1; i++) {
+ dys[i] = ys[i + 1] - ys[i];
+ dxs[i] = xs[i + 1] - xs[i];
+ ds[i] = dys[i] / dxs[i];
+ }
+
+ // Determine desired slope (m) at each point using Fritsch-Carlson method
+ // See: http://math.stackexchange.com/questions/45218/implementation-of-monotone-cubic-interpolation
+
+ ms[0] = ds[0];
+ ms[n - 1] = ds[n - 2];
+
+ for(i = 1; i < n - 1; i++) {
+ if(ds[i] === 0 || ds[i - 1] === 0 || (ds[i - 1] > 0) !== (ds[i] > 0)) {
+ ms[i] = 0;
+ } else {
+ ms[i] = 3 * (dxs[i - 1] + dxs[i]) / (
+ (2 * dxs[i] + dxs[i - 1]) / ds[i - 1] +
+ (dxs[i] + 2 * dxs[i - 1]) / ds[i]);
+
+ if(!isFinite(ms[i])) {
+ ms[i] = 0;
+ }
+ }
+ }
+
+ // Now build a path from the slopes
+
+ path = new Chartist.Svg.Path().move(xs[0], ys[0], false, valueData[0]);
+
+ for(i = 0; i < n - 1; i++) {
+ path.curve(
+ // First control point
+ xs[i] + dxs[i] / 3,
+ ys[i] + ms[i] * dxs[i] / 3,
+ // Second control point
+ xs[i + 1] - dxs[i] / 3,
+ ys[i + 1] - ms[i + 1] * dxs[i] / 3,
+ // End point
+ xs[i + 1],
+ ys[i + 1],
+
+ false,
+ valueData[i + 1]
+ );
+ }
+
+ return path;
+ }
+ };
+ };
+
+ /**
+ * Step interpolation will cause the line chart to move in steps rather than diagonal or smoothed lines. This interpolation will create additional points that will also be drawn when the `showPoint` option is enabled.
+ *
+ * All smoothing functions within Chartist are factory functions that accept an options parameter. The step interpolation function accepts one configuration parameter `postpone`, that can be `true` or `false`. The default value is `true` and will cause the step to occur where the value actually changes. If a different behaviour is needed where the step is shifted to the left and happens before the actual value, this option can be set to `false`.
+ *
+ * @example
+ * var chart = new Chartist.Line('.ct-chart', {
+ * labels: [1, 2, 3, 4, 5],
+ * series: [[1, 2, 8, 1, 7]]
+ * }, {
+ * lineSmooth: Chartist.Interpolation.step({
+ * postpone: true,
+ * fillHoles: false
+ * })
+ * });
+ *
+ * @memberof Chartist.Interpolation
+ * @param options
+ * @returns {Function}
+ */
+ Chartist.Interpolation.step = function(options) {
+ var defaultOptions = {
+ postpone: true,
+ fillHoles: false
+ };
+
+ options = Chartist.extend({}, defaultOptions, options);
+
+ return function step(pathCoordinates, valueData) {
+ var path = new Chartist.Svg.Path();
+
+ var prevX, prevY, prevData;
+
+ for (var i = 0; i < pathCoordinates.length; i += 2) {
+ var currX = pathCoordinates[i];
+ var currY = pathCoordinates[i + 1];
+ var currData = valueData[i / 2];
+
+ // If the current point is also not a hole we can draw the step lines
+ if(currData.value !== undefined) {
+ if(prevData === undefined) {
+ path.move(currX, currY, false, currData);
+ } else {
+ if(options.postpone) {
+ // If postponed we should draw the step line with the value of the previous value
+ path.line(currX, prevY, false, prevData);
+ } else {
+ // If not postponed we should draw the step line with the value of the current value
+ path.line(prevX, currY, false, currData);
+ }
+ // Line to the actual point (this should only be a Y-Axis movement
+ path.line(currX, currY, false, currData);
+ }
+
+ prevX = currX;
+ prevY = currY;
+ prevData = currData;
+ } else if(!options.fillHoles) {
+ prevX = prevY = prevData = undefined;
+ }
+ }
+
+ return path;
+ };
+ };
+
+}(window, document, Chartist));
+;/**
+ * A very basic event module that helps to generate and catch events.
+ *
+ * @module Chartist.Event
+ */
+/* global Chartist */
+(function (window, document, Chartist) {
+ 'use strict';
+
+ Chartist.EventEmitter = function () {
+ var handlers = [];
+
+ /**
+ * Add an event handler for a specific event
+ *
+ * @memberof Chartist.Event
+ * @param {String} event The event name
+ * @param {Function} handler A event handler function
+ */
+ function addEventHandler(event, handler) {
+ handlers[event] = handlers[event] || [];
+ handlers[event].push(handler);
+ }
+
+ /**
+ * Remove an event handler of a specific event name or remove all event handlers for a specific event.
+ *
+ * @memberof Chartist.Event
+ * @param {String} event The event name where a specific or all handlers should be removed
+ * @param {Function} [handler] An optional event handler function. If specified only this specific handler will be removed and otherwise all handlers are removed.
+ */
+ function removeEventHandler(event, handler) {
+ // Only do something if there are event handlers with this name existing
+ if(handlers[event]) {
+ // If handler is set we will look for a specific handler and only remove this
+ if(handler) {
+ handlers[event].splice(handlers[event].indexOf(handler), 1);
+ if(handlers[event].length === 0) {
+ delete handlers[event];
+ }
+ } else {
+ // If no handler is specified we remove all handlers for this event
+ delete handlers[event];
+ }
+ }
+ }
+
+ /**
+ * Use this function to emit an event. All handlers that are listening for this event will be triggered with the data parameter.
+ *
+ * @memberof Chartist.Event
+ * @param {String} event The event name that should be triggered
+ * @param {*} data Arbitrary data that will be passed to the event handler callback functions
+ */
+ function emit(event, data) {
+ // Only do something if there are event handlers with this name existing
+ if(handlers[event]) {
+ handlers[event].forEach(function(handler) {
+ handler(data);
+ });
+ }
+
+ // Emit event to star event handlers
+ if(handlers['*']) {
+ handlers['*'].forEach(function(starHandler) {
+ starHandler(event, data);
+ });
+ }
+ }
+
+ return {
+ addEventHandler: addEventHandler,
+ removeEventHandler: removeEventHandler,
+ emit: emit
+ };
+ };
+
+}(window, document, Chartist));
+;/**
+ * This module provides some basic prototype inheritance utilities.
+ *
+ * @module Chartist.Class
+ */
+/* global Chartist */
+(function(window, document, Chartist) {
+ 'use strict';
+
+ function listToArray(list) {
+ var arr = [];
+ if (list.length) {
+ for (var i = 0; i < list.length; i++) {
+ arr.push(list[i]);
+ }
+ }
+ return arr;
+ }
+
+ /**
+ * Method to extend from current prototype.
+ *
+ * @memberof Chartist.Class
+ * @param {Object} properties The object that serves as definition for the prototype that gets created for the new class. This object should always contain a constructor property that is the desired constructor for the newly created class.
+ * @param {Object} [superProtoOverride] By default extens will use the current class prototype or Chartist.class. With this parameter you can specify any super prototype that will be used.
+ * @return {Function} Constructor function of the new class
+ *
+ * @example
+ * var Fruit = Class.extend({
+ * color: undefined,
+ * sugar: undefined,
+ *
+ * constructor: function(color, sugar) {
+ * this.color = color;
+ * this.sugar = sugar;
+ * },
+ *
+ * eat: function() {
+ * this.sugar = 0;
+ * return this;
+ * }
+ * });
+ *
+ * var Banana = Fruit.extend({
+ * length: undefined,
+ *
+ * constructor: function(length, sugar) {
+ * Banana.super.constructor.call(this, 'Yellow', sugar);
+ * this.length = length;
+ * }
+ * });
+ *
+ * var banana = new Banana(20, 40);
+ * console.log('banana instanceof Fruit', banana instanceof Fruit);
+ * console.log('Fruit is prototype of banana', Fruit.prototype.isPrototypeOf(banana));
+ * console.log('bananas prototype is Fruit', Object.getPrototypeOf(banana) === Fruit.prototype);
+ * console.log(banana.sugar);
+ * console.log(banana.eat().sugar);
+ * console.log(banana.color);
+ */
+ function extend(properties, superProtoOverride) {
+ var superProto = superProtoOverride || this.prototype || Chartist.Class;
+ var proto = Object.create(superProto);
+
+ Chartist.Class.cloneDefinitions(proto, properties);
+
+ var constr = function() {
+ var fn = proto.constructor || function () {},
+ instance;
+
+ // If this is linked to the Chartist namespace the constructor was not called with new
+ // To provide a fallback we will instantiate here and return the instance
+ instance = this === Chartist ? Object.create(proto) : this;
+ fn.apply(instance, Array.prototype.slice.call(arguments, 0));
+
+ // If this constructor was not called with new we need to return the instance
+ // This will not harm when the constructor has been called with new as the returned value is ignored
+ return instance;
+ };
+
+ constr.prototype = proto;
+ constr.super = superProto;
+ constr.extend = this.extend;
+
+ return constr;
+ }
+
+ // Variable argument list clones args > 0 into args[0] and retruns modified args[0]
+ function cloneDefinitions() {
+ var args = listToArray(arguments);
+ var target = args[0];
+
+ args.splice(1, args.length - 1).forEach(function (source) {
+ Object.getOwnPropertyNames(source).forEach(function (propName) {
+ // If this property already exist in target we delete it first
+ delete target[propName];
+ // Define the property with the descriptor from source
+ Object.defineProperty(target, propName,
+ Object.getOwnPropertyDescriptor(source, propName));
+ });
+ });
+
+ return target;
+ }
+
+ Chartist.Class = {
+ extend: extend,
+ cloneDefinitions: cloneDefinitions
+ };
+
+}(window, document, Chartist));
+;/**
+ * Base for all chart types. The methods in Chartist.Base are inherited to all chart types.
+ *
+ * @module Chartist.Base
+ */
+/* global Chartist */
+(function(window, document, Chartist) {
+ 'use strict';
+
+ // TODO: Currently we need to re-draw the chart on window resize. This is usually very bad and will affect performance.
+ // This is done because we can't work with relative coordinates when drawing the chart because SVG Path does not
+ // work with relative positions yet. We need to check if we can do a viewBox hack to switch to percentage.
+ // See http://mozilla.6506.n7.nabble.com/Specyfing-paths-with-percentages-unit-td247474.html
+ // Update: can be done using the above method tested here: http://codepen.io/gionkunz/pen/KDvLj
+ // The problem is with the label offsets that can't be converted into percentage and affecting the chart container
+ /**
+ * Updates the chart which currently does a full reconstruction of the SVG DOM
+ *
+ * @param {Object} [data] Optional data you'd like to set for the chart before it will update. If not specified the update method will use the data that is already configured with the chart.
+ * @param {Object} [options] Optional options you'd like to add to the previous options for the chart before it will update. If not specified the update method will use the options that have been already configured with the chart.
+ * @param {Boolean} [override] If set to true, the passed options will be used to extend the options that have been configured already. Otherwise the chart default options will be used as the base
+ * @memberof Chartist.Base
+ */
+ function update(data, options, override) {
+ if(data) {
+ this.data = data;
+ // Event for data transformation that allows to manipulate the data before it gets rendered in the charts
+ this.eventEmitter.emit('data', {
+ type: 'update',
+ data: this.data
+ });
+ }
+
+ if(options) {
+ this.options = Chartist.extend({}, override ? this.options : this.defaultOptions, options);
+
+ // If chartist was not initialized yet, we just set the options and leave the rest to the initialization
+ // Otherwise we re-create the optionsProvider at this point
+ if(!this.initializeTimeoutId) {
+ this.optionsProvider.removeMediaQueryListeners();
+ this.optionsProvider = Chartist.optionsProvider(this.options, this.responsiveOptions, this.eventEmitter);
+ }
+ }
+
+ // Only re-created the chart if it has been initialized yet
+ if(!this.initializeTimeoutId) {
+ this.createChart(this.optionsProvider.getCurrentOptions());
+ }
+
+ // Return a reference to the chart object to chain up calls
+ return this;
+ }
+
+ /**
+ * This method can be called on the API object of each chart and will un-register all event listeners that were added to other components. This currently includes a window.resize listener as well as media query listeners if any responsive options have been provided. Use this function if you need to destroy and recreate Chartist charts dynamically.
+ *
+ * @memberof Chartist.Base
+ */
+ function detach() {
+ // Only detach if initialization already occurred on this chart. If this chart still hasn't initialized (therefore
+ // the initializationTimeoutId is still a valid timeout reference, we will clear the timeout
+ if(!this.initializeTimeoutId) {
+ window.removeEventListener('resize', this.resizeListener);
+ this.optionsProvider.removeMediaQueryListeners();
+ } else {
+ window.clearTimeout(this.initializeTimeoutId);
+ }
+
+ return this;
+ }
+
+ /**
+ * Use this function to register event handlers. The handler callbacks are synchronous and will run in the main thread rather than the event loop.
+ *
+ * @memberof Chartist.Base
+ * @param {String} event Name of the event. Check the examples for supported events.
+ * @param {Function} handler The handler function that will be called when an event with the given name was emitted. This function will receive a data argument which contains event data. See the example for more details.
+ */
+ function on(event, handler) {
+ this.eventEmitter.addEventHandler(event, handler);
+ return this;
+ }
+
+ /**
+ * Use this function to un-register event handlers. If the handler function parameter is omitted all handlers for the given event will be un-registered.
+ *
+ * @memberof Chartist.Base
+ * @param {String} event Name of the event for which a handler should be removed
+ * @param {Function} [handler] The handler function that that was previously used to register a new event handler. This handler will be removed from the event handler list. If this parameter is omitted then all event handlers for the given event are removed from the list.
+ */
+ function off(event, handler) {
+ this.eventEmitter.removeEventHandler(event, handler);
+ return this;
+ }
+
+ function initialize() {
+ // Add window resize listener that re-creates the chart
+ window.addEventListener('resize', this.resizeListener);
+
+ // Obtain current options based on matching media queries (if responsive options are given)
+ // This will also register a listener that is re-creating the chart based on media changes
+ this.optionsProvider = Chartist.optionsProvider(this.options, this.responsiveOptions, this.eventEmitter);
+ // Register options change listener that will trigger a chart update
+ this.eventEmitter.addEventHandler('optionsChanged', function() {
+ this.update();
+ }.bind(this));
+
+ // Before the first chart creation we need to register us with all plugins that are configured
+ // Initialize all relevant plugins with our chart object and the plugin options specified in the config
+ if(this.options.plugins) {
+ this.options.plugins.forEach(function(plugin) {
+ if(plugin instanceof Array) {
+ plugin[0](this, plugin[1]);
+ } else {
+ plugin(this);
+ }
+ }.bind(this));
+ }
+
+ // Event for data transformation that allows to manipulate the data before it gets rendered in the charts
+ this.eventEmitter.emit('data', {
+ type: 'initial',
+ data: this.data
+ });
+
+ // Create the first chart
+ this.createChart(this.optionsProvider.getCurrentOptions());
+
+ // As chart is initialized from the event loop now we can reset our timeout reference
+ // This is important if the chart gets initialized on the same element twice
+ this.initializeTimeoutId = undefined;
+ }
+
+ /**
+ * Constructor of chart base class.
+ *
+ * @param query
+ * @param data
+ * @param defaultOptions
+ * @param options
+ * @param responsiveOptions
+ * @constructor
+ */
+ function Base(query, data, defaultOptions, options, responsiveOptions) {
+ this.container = Chartist.querySelector(query);
+ this.data = data;
+ this.defaultOptions = defaultOptions;
+ this.options = options;
+ this.responsiveOptions = responsiveOptions;
+ this.eventEmitter = Chartist.EventEmitter();
+ this.supportsForeignObject = Chartist.Svg.isSupported('Extensibility');
+ this.supportsAnimations = Chartist.Svg.isSupported('AnimationEventsAttribute');
+ this.resizeListener = function resizeListener(){
+ this.update();
+ }.bind(this);
+
+ if(this.container) {
+ // If chartist was already initialized in this container we are detaching all event listeners first
+ if(this.container.__chartist__) {
+ this.container.__chartist__.detach();
+ }
+
+ this.container.__chartist__ = this;
+ }
+
+ // Using event loop for first draw to make it possible to register event listeners in the same call stack where
+ // the chart was created.
+ this.initializeTimeoutId = setTimeout(initialize.bind(this), 0);
+ }
+
+ // Creating the chart base class
+ Chartist.Base = Chartist.Class.extend({
+ constructor: Base,
+ optionsProvider: undefined,
+ container: undefined,
+ svg: undefined,
+ eventEmitter: undefined,
+ createChart: function() {
+ throw new Error('Base chart type can\'t be instantiated!');
+ },
+ update: update,
+ detach: detach,
+ on: on,
+ off: off,
+ version: Chartist.version,
+ supportsForeignObject: false
+ });
+
+}(window, document, Chartist));
+;/**
+ * Chartist SVG module for simple SVG DOM abstraction
+ *
+ * @module Chartist.Svg
+ */
+/* global Chartist */
+(function(window, document, Chartist) {
+ 'use strict';
+
+ /**
+ * Chartist.Svg creates a new SVG object wrapper with a starting element. You can use the wrapper to fluently create sub-elements and modify them.
+ *
+ * @memberof Chartist.Svg
+ * @constructor
+ * @param {String|Element} name The name of the SVG element to create or an SVG dom element which should be wrapped into Chartist.Svg
+ * @param {Object} attributes An object with properties that will be added as attributes to the SVG element that is created. Attributes with undefined values will not be added.
+ * @param {String} className This class or class list will be added to the SVG element
+ * @param {Object} parent The parent SVG wrapper object where this newly created wrapper and it's element will be attached to as child
+ * @param {Boolean} insertFirst If this param is set to true in conjunction with a parent element the newly created element will be added as first child element in the parent element
+ */
+ function Svg(name, attributes, className, parent, insertFirst) {
+ // If Svg is getting called with an SVG element we just return the wrapper
+ if(name instanceof Element) {
+ this._node = name;
+ } else {
+ this._node = document.createElementNS(Chartist.namespaces.svg, name);
+
+ // If this is an SVG element created then custom namespace
+ if(name === 'svg') {
+ this.attr({
+ 'xmlns:ct': Chartist.namespaces.ct
+ });
+ }
+ }
+
+ if(attributes) {
+ this.attr(attributes);
+ }
+
+ if(className) {
+ this.addClass(className);
+ }
+
+ if(parent) {
+ if (insertFirst && parent._node.firstChild) {
+ parent._node.insertBefore(this._node, parent._node.firstChild);
+ } else {
+ parent._node.appendChild(this._node);
+ }
+ }
+ }
+
+ /**
+ * Set attributes on the current SVG element of the wrapper you're currently working on.
+ *
+ * @memberof Chartist.Svg
+ * @param {Object|String} attributes An object with properties that will be added as attributes to the SVG element that is created. Attributes with undefined values will not be added. If this parameter is a String then the function is used as a getter and will return the attribute value.
+ * @param {String} ns If specified, the attribute will be obtained using getAttributeNs. In order to write namepsaced attributes you can use the namespace:attribute notation within the attributes object.
+ * @return {Object|String} The current wrapper object will be returned so it can be used for chaining or the attribute value if used as getter function.
+ */
+ function attr(attributes, ns) {
+ if(typeof attributes === 'string') {
+ if(ns) {
+ return this._node.getAttributeNS(ns, attributes);
+ } else {
+ return this._node.getAttribute(attributes);
+ }
+ }
+
+ Object.keys(attributes).forEach(function(key) {
+ // If the attribute value is undefined we can skip this one
+ if(attributes[key] === undefined) {
+ return;
+ }
+
+ if (key.indexOf(':') !== -1) {
+ var namespacedAttribute = key.split(':');
+ this._node.setAttributeNS(Chartist.namespaces[namespacedAttribute[0]], key, attributes[key]);
+ } else {
+ this._node.setAttribute(key, attributes[key]);
+ }
+ }.bind(this));
+
+ return this;
+ }
+
+ /**
+ * Create a new SVG element whose wrapper object will be selected for further operations. This way you can also create nested groups easily.
+ *
+ * @memberof Chartist.Svg
+ * @param {String} name The name of the SVG element that should be created as child element of the currently selected element wrapper
+ * @param {Object} [attributes] An object with properties that will be added as attributes to the SVG element that is created. Attributes with undefined values will not be added.
+ * @param {String} [className] This class or class list will be added to the SVG element
+ * @param {Boolean} [insertFirst] If this param is set to true in conjunction with a parent element the newly created element will be added as first child element in the parent element
+ * @return {Chartist.Svg} Returns a Chartist.Svg wrapper object that can be used to modify the containing SVG data
+ */
+ function elem(name, attributes, className, insertFirst) {
+ return new Chartist.Svg(name, attributes, className, this, insertFirst);
+ }
+
+ /**
+ * Returns the parent Chartist.SVG wrapper object
+ *
+ * @memberof Chartist.Svg
+ * @return {Chartist.Svg} Returns a Chartist.Svg wrapper around the parent node of the current node. If the parent node is not existing or it's not an SVG node then this function will return null.
+ */
+ function parent() {
+ return this._node.parentNode instanceof SVGElement ? new Chartist.Svg(this._node.parentNode) : null;
+ }
+
+ /**
+ * This method returns a Chartist.Svg wrapper around the root SVG element of the current tree.
+ *
+ * @memberof Chartist.Svg
+ * @return {Chartist.Svg} The root SVG element wrapped in a Chartist.Svg element
+ */
+ function root() {
+ var node = this._node;
+ while(node.nodeName !== 'svg') {
+ node = node.parentNode;
+ }
+ return new Chartist.Svg(node);
+ }
+
+ /**
+ * Find the first child SVG element of the current element that matches a CSS selector. The returned object is a Chartist.Svg wrapper.
+ *
+ * @memberof Chartist.Svg
+ * @param {String} selector A CSS selector that is used to query for child SVG elements
+ * @return {Chartist.Svg} The SVG wrapper for the element found or null if no element was found
+ */
+ function querySelector(selector) {
+ var foundNode = this._node.querySelector(selector);
+ return foundNode ? new Chartist.Svg(foundNode) : null;
+ }
+
+ /**
+ * Find the all child SVG elements of the current element that match a CSS selector. The returned object is a Chartist.Svg.List wrapper.
+ *
+ * @memberof Chartist.Svg
+ * @param {String} selector A CSS selector that is used to query for child SVG elements
+ * @return {Chartist.Svg.List} The SVG wrapper list for the element found or null if no element was found
+ */
+ function querySelectorAll(selector) {
+ var foundNodes = this._node.querySelectorAll(selector);
+ return foundNodes.length ? new Chartist.Svg.List(foundNodes) : null;
+ }
+
+ /**
+ * This method creates a foreignObject (see https://developer.mozilla.org/en-US/docs/Web/SVG/Element/foreignObject) that allows to embed HTML content into a SVG graphic. With the help of foreignObjects you can enable the usage of regular HTML elements inside of SVG where they are subject for SVG positioning and transformation but the Browser will use the HTML rendering capabilities for the containing DOM.
+ *
+ * @memberof Chartist.Svg
+ * @param {Node|String} content The DOM Node, or HTML string that will be converted to a DOM Node, that is then placed into and wrapped by the foreignObject
+ * @param {String} [attributes] An object with properties that will be added as attributes to the foreignObject element that is created. Attributes with undefined values will not be added.
+ * @param {String} [className] This class or class list will be added to the SVG element
+ * @param {Boolean} [insertFirst] Specifies if the foreignObject should be inserted as first child
+ * @return {Chartist.Svg} New wrapper object that wraps the foreignObject element
+ */
+ function foreignObject(content, attributes, className, insertFirst) {
+ // If content is string then we convert it to DOM
+ // TODO: Handle case where content is not a string nor a DOM Node
+ if(typeof content === 'string') {
+ var container = document.createElement('div');
+ container.innerHTML = content;
+ content = container.firstChild;
+ }
+
+ // Adding namespace to content element
+ content.setAttribute('xmlns', Chartist.namespaces.xmlns);
+
+ // Creating the foreignObject without required extension attribute (as described here
+ // http://www.w3.org/TR/SVG/extend.html#ForeignObjectElement)
+ var fnObj = this.elem('foreignObject', attributes, className, insertFirst);
+
+ // Add content to foreignObjectElement
+ fnObj._node.appendChild(content);
+
+ return fnObj;
+ }
+
+ /**
+ * This method adds a new text element to the current Chartist.Svg wrapper.
+ *
+ * @memberof Chartist.Svg
+ * @param {String} t The text that should be added to the text element that is created
+ * @return {Chartist.Svg} The same wrapper object that was used to add the newly created element
+ */
+ function text(t) {
+ this._node.appendChild(document.createTextNode(t));
+ return this;
+ }
+
+ /**
+ * This method will clear all child nodes of the current wrapper object.
+ *
+ * @memberof Chartist.Svg
+ * @return {Chartist.Svg} The same wrapper object that got emptied
+ */
+ function empty() {
+ while (this._node.firstChild) {
+ this._node.removeChild(this._node.firstChild);
+ }
+
+ return this;
+ }
+
+ /**
+ * This method will cause the current wrapper to remove itself from its parent wrapper. Use this method if you'd like to get rid of an element in a given DOM structure.
+ *
+ * @memberof Chartist.Svg
+ * @return {Chartist.Svg} The parent wrapper object of the element that got removed
+ */
+ function remove() {
+ this._node.parentNode.removeChild(this._node);
+ return this.parent();
+ }
+
+ /**
+ * This method will replace the element with a new element that can be created outside of the current DOM.
+ *
+ * @memberof Chartist.Svg
+ * @param {Chartist.Svg} newElement The new Chartist.Svg object that will be used to replace the current wrapper object
+ * @return {Chartist.Svg} The wrapper of the new element
+ */
+ function replace(newElement) {
+ this._node.parentNode.replaceChild(newElement._node, this._node);
+ return newElement;
+ }
+
+ /**
+ * This method will append an element to the current element as a child.
+ *
+ * @memberof Chartist.Svg
+ * @param {Chartist.Svg} element The Chartist.Svg element that should be added as a child
+ * @param {Boolean} [insertFirst] Specifies if the element should be inserted as first child
+ * @return {Chartist.Svg} The wrapper of the appended object
+ */
+ function append(element, insertFirst) {
+ if(insertFirst && this._node.firstChild) {
+ this._node.insertBefore(element._node, this._node.firstChild);
+ } else {
+ this._node.appendChild(element._node);
+ }
+
+ return this;
+ }
+
+ /**
+ * Returns an array of class names that are attached to the current wrapper element. This method can not be chained further.
+ *
+ * @memberof Chartist.Svg
+ * @return {Array} A list of classes or an empty array if there are no classes on the current element
+ */
+ function classes() {
+ return this._node.getAttribute('class') ? this._node.getAttribute('class').trim().split(/\s+/) : [];
+ }
+
+ /**
+ * Adds one or a space separated list of classes to the current element and ensures the classes are only existing once.
+ *
+ * @memberof Chartist.Svg
+ * @param {String} names A white space separated list of class names
+ * @return {Chartist.Svg} The wrapper of the current element
+ */
+ function addClass(names) {
+ this._node.setAttribute('class',
+ this.classes(this._node)
+ .concat(names.trim().split(/\s+/))
+ .filter(function(elem, pos, self) {
+ return self.indexOf(elem) === pos;
+ }).join(' ')
+ );
+
+ return this;
+ }
+
+ /**
+ * Removes one or a space separated list of classes from the current element.
+ *
+ * @memberof Chartist.Svg
+ * @param {String} names A white space separated list of class names
+ * @return {Chartist.Svg} The wrapper of the current element
+ */
+ function removeClass(names) {
+ var removedClasses = names.trim().split(/\s+/);
+
+ this._node.setAttribute('class', this.classes(this._node).filter(function(name) {
+ return removedClasses.indexOf(name) === -1;
+ }).join(' '));
+
+ return this;
+ }
+
+ /**
+ * Removes all classes from the current element.
+ *
+ * @memberof Chartist.Svg
+ * @return {Chartist.Svg} The wrapper of the current element
+ */
+ function removeAllClasses() {
+ this._node.setAttribute('class', '');
+
+ return this;
+ }
+
+ /**
+ * Get element height using `getBoundingClientRect`
+ *
+ * @memberof Chartist.Svg
+ * @return {Number} The elements height in pixels
+ */
+ function height() {
+ return this._node.getBoundingClientRect().height;
+ }
+
+ /**
+ * Get element width using `getBoundingClientRect`
+ *
+ * @memberof Chartist.Core
+ * @return {Number} The elements width in pixels
+ */
+ function width() {
+ return this._node.getBoundingClientRect().width;
+ }
+
+ /**
+ * The animate function lets you animate the current element with SMIL animations. You can add animations for multiple attributes at the same time by using an animation definition object. This object should contain SMIL animation attributes. Please refer to http://www.w3.org/TR/SVG/animate.html for a detailed specification about the available animation attributes. Additionally an easing property can be passed in the animation definition object. This can be a string with a name of an easing function in `Chartist.Svg.Easing` or an array with four numbers specifying a cubic Bézier curve.
+ * **An animations object could look like this:**
+ * ```javascript
+ * element.animate({
+ * opacity: {
+ * dur: 1000,
+ * from: 0,
+ * to: 1
+ * },
+ * x1: {
+ * dur: '1000ms',
+ * from: 100,
+ * to: 200,
+ * easing: 'easeOutQuart'
+ * },
+ * y1: {
+ * dur: '2s',
+ * from: 0,
+ * to: 100
+ * }
+ * });
+ * ```
+ * **Automatic unit conversion**
+ * For the `dur` and the `begin` animate attribute you can also omit a unit by passing a number. The number will automatically be converted to milli seconds.
+ * **Guided mode**
+ * The default behavior of SMIL animations with offset using the `begin` attribute is that the attribute will keep it's original value until the animation starts. Mostly this behavior is not desired as you'd like to have your element attributes already initialized with the animation `from` value even before the animation starts. Also if you don't specify `fill="freeze"` on an animate element or if you delete the animation after it's done (which is done in guided mode) the attribute will switch back to the initial value. This behavior is also not desired when performing simple one-time animations. For one-time animations you'd want to trigger animations immediately instead of relative to the document begin time. That's why in guided mode Chartist.Svg will also use the `begin` property to schedule a timeout and manually start the animation after the timeout. If you're using multiple SMIL definition objects for an attribute (in an array), guided mode will be disabled for this attribute, even if you explicitly enabled it.
+ * If guided mode is enabled the following behavior is added:
+ * - Before the animation starts (even when delayed with `begin`) the animated attribute will be set already to the `from` value of the animation
+ * - `begin` is explicitly set to `indefinite` so it can be started manually without relying on document begin time (creation)
+ * - The animate element will be forced to use `fill="freeze"`
+ * - The animation will be triggered with `beginElement()` in a timeout where `begin` of the definition object is interpreted in milli seconds. If no `begin` was specified the timeout is triggered immediately.
+ * - After the animation the element attribute value will be set to the `to` value of the animation
+ * - The animate element is deleted from the DOM
+ *
+ * @memberof Chartist.Svg
+ * @param {Object} animations An animations object where the property keys are the attributes you'd like to animate. The properties should be objects again that contain the SMIL animation attributes (usually begin, dur, from, and to). The property begin and dur is auto converted (see Automatic unit conversion). You can also schedule multiple animations for the same attribute by passing an Array of SMIL definition objects. Attributes that contain an array of SMIL definition objects will not be executed in guided mode.
+ * @param {Boolean} guided Specify if guided mode should be activated for this animation (see Guided mode). If not otherwise specified, guided mode will be activated.
+ * @param {Object} eventEmitter If specified, this event emitter will be notified when an animation starts or ends.
+ * @return {Chartist.Svg} The current element where the animation was added
+ */
+ function animate(animations, guided, eventEmitter) {
+ if(guided === undefined) {
+ guided = true;
+ }
+
+ Object.keys(animations).forEach(function createAnimateForAttributes(attribute) {
+
+ function createAnimate(animationDefinition, guided) {
+ var attributeProperties = {},
+ animate,
+ timeout,
+ easing;
+
+ // Check if an easing is specified in the definition object and delete it from the object as it will not
+ // be part of the animate element attributes.
+ if(animationDefinition.easing) {
+ // If already an easing Bézier curve array we take it or we lookup a easing array in the Easing object
+ easing = animationDefinition.easing instanceof Array ?
+ animationDefinition.easing :
+ Chartist.Svg.Easing[animationDefinition.easing];
+ delete animationDefinition.easing;
+ }
+
+ // If numeric dur or begin was provided we assume milli seconds
+ animationDefinition.begin = Chartist.ensureUnit(animationDefinition.begin, 'ms');
+ animationDefinition.dur = Chartist.ensureUnit(animationDefinition.dur, 'ms');
+
+ if(easing) {
+ animationDefinition.calcMode = 'spline';
+ animationDefinition.keySplines = easing.join(' ');
+ animationDefinition.keyTimes = '0;1';
+ }
+
+ // Adding "fill: freeze" if we are in guided mode and set initial attribute values
+ if(guided) {
+ animationDefinition.fill = 'freeze';
+ // Animated property on our element should already be set to the animation from value in guided mode
+ attributeProperties[attribute] = animationDefinition.from;
+ this.attr(attributeProperties);
+
+ // In guided mode we also set begin to indefinite so we can trigger the start manually and put the begin
+ // which needs to be in ms aside
+ timeout = Chartist.quantity(animationDefinition.begin || 0).value;
+ animationDefinition.begin = 'indefinite';
+ }
+
+ animate = this.elem('animate', Chartist.extend({
+ attributeName: attribute
+ }, animationDefinition));
+
+ if(guided) {
+ // If guided we take the value that was put aside in timeout and trigger the animation manually with a timeout
+ setTimeout(function() {
+ // If beginElement fails we set the animated attribute to the end position and remove the animate element
+ // This happens if the SMIL ElementTimeControl interface is not supported or any other problems occured in
+ // the browser. (Currently FF 34 does not support animate elements in foreignObjects)
+ try {
+ animate._node.beginElement();
+ } catch(err) {
+ // Set animated attribute to current animated value
+ attributeProperties[attribute] = animationDefinition.to;
+ this.attr(attributeProperties);
+ // Remove the animate element as it's no longer required
+ animate.remove();
+ }
+ }.bind(this), timeout);
+ }
+
+ if(eventEmitter) {
+ animate._node.addEventListener('beginEvent', function handleBeginEvent() {
+ eventEmitter.emit('animationBegin', {
+ element: this,
+ animate: animate._node,
+ params: animationDefinition
+ });
+ }.bind(this));
+ }
+
+ animate._node.addEventListener('endEvent', function handleEndEvent() {
+ if(eventEmitter) {
+ eventEmitter.emit('animationEnd', {
+ element: this,
+ animate: animate._node,
+ params: animationDefinition
+ });
+ }
+
+ if(guided) {
+ // Set animated attribute to current animated value
+ attributeProperties[attribute] = animationDefinition.to;
+ this.attr(attributeProperties);
+ // Remove the animate element as it's no longer required
+ animate.remove();
+ }
+ }.bind(this));
+ }
+
+ // If current attribute is an array of definition objects we create an animate for each and disable guided mode
+ if(animations[attribute] instanceof Array) {
+ animations[attribute].forEach(function(animationDefinition) {
+ createAnimate.bind(this)(animationDefinition, false);
+ }.bind(this));
+ } else {
+ createAnimate.bind(this)(animations[attribute], guided);
+ }
+
+ }.bind(this));
+
+ return this;
+ }
+
+ Chartist.Svg = Chartist.Class.extend({
+ constructor: Svg,
+ attr: attr,
+ elem: elem,
+ parent: parent,
+ root: root,
+ querySelector: querySelector,
+ querySelectorAll: querySelectorAll,
+ foreignObject: foreignObject,
+ text: text,
+ empty: empty,
+ remove: remove,
+ replace: replace,
+ append: append,
+ classes: classes,
+ addClass: addClass,
+ removeClass: removeClass,
+ removeAllClasses: removeAllClasses,
+ height: height,
+ width: width,
+ animate: animate
+ });
+
+ /**
+ * This method checks for support of a given SVG feature like Extensibility, SVG-animation or the like. Check http://www.w3.org/TR/SVG11/feature for a detailed list.
+ *
+ * @memberof Chartist.Svg
+ * @param {String} feature The SVG 1.1 feature that should be checked for support.
+ * @return {Boolean} True of false if the feature is supported or not
+ */
+ Chartist.Svg.isSupported = function(feature) {
+ return document.implementation.hasFeature('http://www.w3.org/TR/SVG11/feature#' + feature, '1.1');
+ };
+
+ /**
+ * This Object contains some standard easing cubic bezier curves. Then can be used with their name in the `Chartist.Svg.animate`. You can also extend the list and use your own name in the `animate` function. Click the show code button to see the available bezier functions.
+ *
+ * @memberof Chartist.Svg
+ */
+ var easingCubicBeziers = {
+ easeInSine: [0.47, 0, 0.745, 0.715],
+ easeOutSine: [0.39, 0.575, 0.565, 1],
+ easeInOutSine: [0.445, 0.05, 0.55, 0.95],
+ easeInQuad: [0.55, 0.085, 0.68, 0.53],
+ easeOutQuad: [0.25, 0.46, 0.45, 0.94],
+ easeInOutQuad: [0.455, 0.03, 0.515, 0.955],
+ easeInCubic: [0.55, 0.055, 0.675, 0.19],
+ easeOutCubic: [0.215, 0.61, 0.355, 1],
+ easeInOutCubic: [0.645, 0.045, 0.355, 1],
+ easeInQuart: [0.895, 0.03, 0.685, 0.22],
+ easeOutQuart: [0.165, 0.84, 0.44, 1],
+ easeInOutQuart: [0.77, 0, 0.175, 1],
+ easeInQuint: [0.755, 0.05, 0.855, 0.06],
+ easeOutQuint: [0.23, 1, 0.32, 1],
+ easeInOutQuint: [0.86, 0, 0.07, 1],
+ easeInExpo: [0.95, 0.05, 0.795, 0.035],
+ easeOutExpo: [0.19, 1, 0.22, 1],
+ easeInOutExpo: [1, 0, 0, 1],
+ easeInCirc: [0.6, 0.04, 0.98, 0.335],
+ easeOutCirc: [0.075, 0.82, 0.165, 1],
+ easeInOutCirc: [0.785, 0.135, 0.15, 0.86],
+ easeInBack: [0.6, -0.28, 0.735, 0.045],
+ easeOutBack: [0.175, 0.885, 0.32, 1.275],
+ easeInOutBack: [0.68, -0.55, 0.265, 1.55]
+ };
+
+ Chartist.Svg.Easing = easingCubicBeziers;
+
+ /**
+ * This helper class is to wrap multiple `Chartist.Svg` elements into a list where you can call the `Chartist.Svg` functions on all elements in the list with one call. This is helpful when you'd like to perform calls with `Chartist.Svg` on multiple elements.
+ * An instance of this class is also returned by `Chartist.Svg.querySelectorAll`.
+ *
+ * @memberof Chartist.Svg
+ * @param {Array
|NodeList} nodeList An Array of SVG DOM nodes or a SVG DOM NodeList (as returned by document.querySelectorAll)
+ * @constructor
+ */
+ function SvgList(nodeList) {
+ var list = this;
+
+ this.svgElements = [];
+ for(var i = 0; i < nodeList.length; i++) {
+ this.svgElements.push(new Chartist.Svg(nodeList[i]));
+ }
+
+ // Add delegation methods for Chartist.Svg
+ Object.keys(Chartist.Svg.prototype).filter(function(prototypeProperty) {
+ return ['constructor',
+ 'parent',
+ 'querySelector',
+ 'querySelectorAll',
+ 'replace',
+ 'append',
+ 'classes',
+ 'height',
+ 'width'].indexOf(prototypeProperty) === -1;
+ }).forEach(function(prototypeProperty) {
+ list[prototypeProperty] = function() {
+ var args = Array.prototype.slice.call(arguments, 0);
+ list.svgElements.forEach(function(element) {
+ Chartist.Svg.prototype[prototypeProperty].apply(element, args);
+ });
+ return list;
+ };
+ });
+ }
+
+ Chartist.Svg.List = Chartist.Class.extend({
+ constructor: SvgList
+ });
+}(window, document, Chartist));
+;/**
+ * Chartist SVG path module for SVG path description creation and modification.
+ *
+ * @module Chartist.Svg.Path
+ */
+/* global Chartist */
+(function(window, document, Chartist) {
+ 'use strict';
+
+ /**
+ * Contains the descriptors of supported element types in a SVG path. Currently only move, line and curve are supported.
+ *
+ * @memberof Chartist.Svg.Path
+ * @type {Object}
+ */
+ var elementDescriptions = {
+ m: ['x', 'y'],
+ l: ['x', 'y'],
+ c: ['x1', 'y1', 'x2', 'y2', 'x', 'y'],
+ a: ['rx', 'ry', 'xAr', 'lAf', 'sf', 'x', 'y']
+ };
+
+ /**
+ * Default options for newly created SVG path objects.
+ *
+ * @memberof Chartist.Svg.Path
+ * @type {Object}
+ */
+ var defaultOptions = {
+ // The accuracy in digit count after the decimal point. This will be used to round numbers in the SVG path. If this option is set to false then no rounding will be performed.
+ accuracy: 3
+ };
+
+ function element(command, params, pathElements, pos, relative, data) {
+ var pathElement = Chartist.extend({
+ command: relative ? command.toLowerCase() : command.toUpperCase()
+ }, params, data ? { data: data } : {} );
+
+ pathElements.splice(pos, 0, pathElement);
+ }
+
+ function forEachParam(pathElements, cb) {
+ pathElements.forEach(function(pathElement, pathElementIndex) {
+ elementDescriptions[pathElement.command.toLowerCase()].forEach(function(paramName, paramIndex) {
+ cb(pathElement, paramName, pathElementIndex, paramIndex, pathElements);
+ });
+ });
+ }
+
+ /**
+ * Used to construct a new path object.
+ *
+ * @memberof Chartist.Svg.Path
+ * @param {Boolean} close If set to true then this path will be closed when stringified (with a Z at the end)
+ * @param {Object} options Options object that overrides the default objects. See default options for more details.
+ * @constructor
+ */
+ function SvgPath(close, options) {
+ this.pathElements = [];
+ this.pos = 0;
+ this.close = close;
+ this.options = Chartist.extend({}, defaultOptions, options);
+ }
+
+ /**
+ * Gets or sets the current position (cursor) inside of the path. You can move around the cursor freely but limited to 0 or the count of existing elements. All modifications with element functions will insert new elements at the position of this cursor.
+ *
+ * @memberof Chartist.Svg.Path
+ * @param {Number} [pos] If a number is passed then the cursor is set to this position in the path element array.
+ * @return {Chartist.Svg.Path|Number} If the position parameter was passed then the return value will be the path object for easy call chaining. If no position parameter was passed then the current position is returned.
+ */
+ function position(pos) {
+ if(pos !== undefined) {
+ this.pos = Math.max(0, Math.min(this.pathElements.length, pos));
+ return this;
+ } else {
+ return this.pos;
+ }
+ }
+
+ /**
+ * Removes elements from the path starting at the current position.
+ *
+ * @memberof Chartist.Svg.Path
+ * @param {Number} count Number of path elements that should be removed from the current position.
+ * @return {Chartist.Svg.Path} The current path object for easy call chaining.
+ */
+ function remove(count) {
+ this.pathElements.splice(this.pos, count);
+ return this;
+ }
+
+ /**
+ * Use this function to add a new move SVG path element.
+ *
+ * @memberof Chartist.Svg.Path
+ * @param {Number} x The x coordinate for the move element.
+ * @param {Number} y The y coordinate for the move element.
+ * @param {Boolean} [relative] If set to true the move element will be created with relative coordinates (lowercase letter)
+ * @param {*} [data] Any data that should be stored with the element object that will be accessible in pathElement
+ * @return {Chartist.Svg.Path} The current path object for easy call chaining.
+ */
+ function move(x, y, relative, data) {
+ element('M', {
+ x: +x,
+ y: +y
+ }, this.pathElements, this.pos++, relative, data);
+ return this;
+ }
+
+ /**
+ * Use this function to add a new line SVG path element.
+ *
+ * @memberof Chartist.Svg.Path
+ * @param {Number} x The x coordinate for the line element.
+ * @param {Number} y The y coordinate for the line element.
+ * @param {Boolean} [relative] If set to true the line element will be created with relative coordinates (lowercase letter)
+ * @param {*} [data] Any data that should be stored with the element object that will be accessible in pathElement
+ * @return {Chartist.Svg.Path} The current path object for easy call chaining.
+ */
+ function line(x, y, relative, data) {
+ element('L', {
+ x: +x,
+ y: +y
+ }, this.pathElements, this.pos++, relative, data);
+ return this;
+ }
+
+ /**
+ * Use this function to add a new curve SVG path element.
+ *
+ * @memberof Chartist.Svg.Path
+ * @param {Number} x1 The x coordinate for the first control point of the bezier curve.
+ * @param {Number} y1 The y coordinate for the first control point of the bezier curve.
+ * @param {Number} x2 The x coordinate for the second control point of the bezier curve.
+ * @param {Number} y2 The y coordinate for the second control point of the bezier curve.
+ * @param {Number} x The x coordinate for the target point of the curve element.
+ * @param {Number} y The y coordinate for the target point of the curve element.
+ * @param {Boolean} [relative] If set to true the curve element will be created with relative coordinates (lowercase letter)
+ * @param {*} [data] Any data that should be stored with the element object that will be accessible in pathElement
+ * @return {Chartist.Svg.Path} The current path object for easy call chaining.
+ */
+ function curve(x1, y1, x2, y2, x, y, relative, data) {
+ element('C', {
+ x1: +x1,
+ y1: +y1,
+ x2: +x2,
+ y2: +y2,
+ x: +x,
+ y: +y
+ }, this.pathElements, this.pos++, relative, data);
+ return this;
+ }
+
+ /**
+ * Use this function to add a new non-bezier curve SVG path element.
+ *
+ * @memberof Chartist.Svg.Path
+ * @param {Number} rx The radius to be used for the x-axis of the arc.
+ * @param {Number} ry The radius to be used for the y-axis of the arc.
+ * @param {Number} xAr Defines the orientation of the arc
+ * @param {Number} lAf Large arc flag
+ * @param {Number} sf Sweep flag
+ * @param {Number} x The x coordinate for the target point of the curve element.
+ * @param {Number} y The y coordinate for the target point of the curve element.
+ * @param {Boolean} [relative] If set to true the curve element will be created with relative coordinates (lowercase letter)
+ * @param {*} [data] Any data that should be stored with the element object that will be accessible in pathElement
+ * @return {Chartist.Svg.Path} The current path object for easy call chaining.
+ */
+ function arc(rx, ry, xAr, lAf, sf, x, y, relative, data) {
+ element('A', {
+ rx: +rx,
+ ry: +ry,
+ xAr: +xAr,
+ lAf: +lAf,
+ sf: +sf,
+ x: +x,
+ y: +y
+ }, this.pathElements, this.pos++, relative, data);
+ return this;
+ }
+
+ /**
+ * Parses an SVG path seen in the d attribute of path elements, and inserts the parsed elements into the existing path object at the current cursor position. Any closing path indicators (Z at the end of the path) will be ignored by the parser as this is provided by the close option in the options of the path object.
+ *
+ * @memberof Chartist.Svg.Path
+ * @param {String} path Any SVG path that contains move (m), line (l) or curve (c) components.
+ * @return {Chartist.Svg.Path} The current path object for easy call chaining.
+ */
+ function parse(path) {
+ // Parsing the SVG path string into an array of arrays [['M', '10', '10'], ['L', '100', '100']]
+ var chunks = path.replace(/([A-Za-z])([0-9])/g, '$1 $2')
+ .replace(/([0-9])([A-Za-z])/g, '$1 $2')
+ .split(/[\s,]+/)
+ .reduce(function(result, element) {
+ if(element.match(/[A-Za-z]/)) {
+ result.push([]);
+ }
+
+ result[result.length - 1].push(element);
+ return result;
+ }, []);
+
+ // If this is a closed path we remove the Z at the end because this is determined by the close option
+ if(chunks[chunks.length - 1][0].toUpperCase() === 'Z') {
+ chunks.pop();
+ }
+
+ // Using svgPathElementDescriptions to map raw path arrays into objects that contain the command and the parameters
+ // For example {command: 'M', x: '10', y: '10'}
+ var elements = chunks.map(function(chunk) {
+ var command = chunk.shift(),
+ description = elementDescriptions[command.toLowerCase()];
+
+ return Chartist.extend({
+ command: command
+ }, description.reduce(function(result, paramName, index) {
+ result[paramName] = +chunk[index];
+ return result;
+ }, {}));
+ });
+
+ // Preparing a splice call with the elements array as var arg params and insert the parsed elements at the current position
+ var spliceArgs = [this.pos, 0];
+ Array.prototype.push.apply(spliceArgs, elements);
+ Array.prototype.splice.apply(this.pathElements, spliceArgs);
+ // Increase the internal position by the element count
+ this.pos += elements.length;
+
+ return this;
+ }
+
+ /**
+ * This function renders to current SVG path object into a final SVG string that can be used in the d attribute of SVG path elements. It uses the accuracy option to round big decimals. If the close parameter was set in the constructor of this path object then a path closing Z will be appended to the output string.
+ *
+ * @memberof Chartist.Svg.Path
+ * @return {String}
+ */
+ function stringify() {
+ var accuracyMultiplier = Math.pow(10, this.options.accuracy);
+
+ return this.pathElements.reduce(function(path, pathElement) {
+ var params = elementDescriptions[pathElement.command.toLowerCase()].map(function(paramName) {
+ return this.options.accuracy ?
+ (Math.round(pathElement[paramName] * accuracyMultiplier) / accuracyMultiplier) :
+ pathElement[paramName];
+ }.bind(this));
+
+ return path + pathElement.command + params.join(',');
+ }.bind(this), '') + (this.close ? 'Z' : '');
+ }
+
+ /**
+ * Scales all elements in the current SVG path object. There is an individual parameter for each coordinate. Scaling will also be done for control points of curves, affecting the given coordinate.
+ *
+ * @memberof Chartist.Svg.Path
+ * @param {Number} x The number which will be used to scale the x, x1 and x2 of all path elements.
+ * @param {Number} y The number which will be used to scale the y, y1 and y2 of all path elements.
+ * @return {Chartist.Svg.Path} The current path object for easy call chaining.
+ */
+ function scale(x, y) {
+ forEachParam(this.pathElements, function(pathElement, paramName) {
+ pathElement[paramName] *= paramName[0] === 'x' ? x : y;
+ });
+ return this;
+ }
+
+ /**
+ * Translates all elements in the current SVG path object. The translation is relative and there is an individual parameter for each coordinate. Translation will also be done for control points of curves, affecting the given coordinate.
+ *
+ * @memberof Chartist.Svg.Path
+ * @param {Number} x The number which will be used to translate the x, x1 and x2 of all path elements.
+ * @param {Number} y The number which will be used to translate the y, y1 and y2 of all path elements.
+ * @return {Chartist.Svg.Path} The current path object for easy call chaining.
+ */
+ function translate(x, y) {
+ forEachParam(this.pathElements, function(pathElement, paramName) {
+ pathElement[paramName] += paramName[0] === 'x' ? x : y;
+ });
+ return this;
+ }
+
+ /**
+ * This function will run over all existing path elements and then loop over their attributes. The callback function will be called for every path element attribute that exists in the current path.
+ * The method signature of the callback function looks like this:
+ * ```javascript
+ * function(pathElement, paramName, pathElementIndex, paramIndex, pathElements)
+ * ```
+ * If something else than undefined is returned by the callback function, this value will be used to replace the old value. This allows you to build custom transformations of path objects that can't be achieved using the basic transformation functions scale and translate.
+ *
+ * @memberof Chartist.Svg.Path
+ * @param {Function} transformFnc The callback function for the transformation. Check the signature in the function description.
+ * @return {Chartist.Svg.Path} The current path object for easy call chaining.
+ */
+ function transform(transformFnc) {
+ forEachParam(this.pathElements, function(pathElement, paramName, pathElementIndex, paramIndex, pathElements) {
+ var transformed = transformFnc(pathElement, paramName, pathElementIndex, paramIndex, pathElements);
+ if(transformed || transformed === 0) {
+ pathElement[paramName] = transformed;
+ }
+ });
+ return this;
+ }
+
+ /**
+ * This function clones a whole path object with all its properties. This is a deep clone and path element objects will also be cloned.
+ *
+ * @memberof Chartist.Svg.Path
+ * @param {Boolean} [close] Optional option to set the new cloned path to closed. If not specified or false, the original path close option will be used.
+ * @return {Chartist.Svg.Path}
+ */
+ function clone(close) {
+ var c = new Chartist.Svg.Path(close || this.close);
+ c.pos = this.pos;
+ c.pathElements = this.pathElements.slice().map(function cloneElements(pathElement) {
+ return Chartist.extend({}, pathElement);
+ });
+ c.options = Chartist.extend({}, this.options);
+ return c;
+ }
+
+ /**
+ * Split a Svg.Path object by a specific command in the path chain. The path chain will be split and an array of newly created paths objects will be returned. This is useful if you'd like to split an SVG path by it's move commands, for example, in order to isolate chunks of drawings.
+ *
+ * @memberof Chartist.Svg.Path
+ * @param {String} command The command you'd like to use to split the path
+ * @return {Array}
+ */
+ function splitByCommand(command) {
+ var split = [
+ new Chartist.Svg.Path()
+ ];
+
+ this.pathElements.forEach(function(pathElement) {
+ if(pathElement.command === command.toUpperCase() && split[split.length - 1].pathElements.length !== 0) {
+ split.push(new Chartist.Svg.Path());
+ }
+
+ split[split.length - 1].pathElements.push(pathElement);
+ });
+
+ return split;
+ }
+
+ /**
+ * This static function on `Chartist.Svg.Path` is joining multiple paths together into one paths.
+ *
+ * @memberof Chartist.Svg.Path
+ * @param {Array} paths A list of paths to be joined together. The order is important.
+ * @param {boolean} close If the newly created path should be a closed path
+ * @param {Object} options Path options for the newly created path.
+ * @return {Chartist.Svg.Path}
+ */
+
+ function join(paths, close, options) {
+ var joinedPath = new Chartist.Svg.Path(close, options);
+ for(var i = 0; i < paths.length; i++) {
+ var path = paths[i];
+ for(var j = 0; j < path.pathElements.length; j++) {
+ joinedPath.pathElements.push(path.pathElements[j]);
+ }
+ }
+ return joinedPath;
+ }
+
+ Chartist.Svg.Path = Chartist.Class.extend({
+ constructor: SvgPath,
+ position: position,
+ remove: remove,
+ move: move,
+ line: line,
+ curve: curve,
+ arc: arc,
+ scale: scale,
+ translate: translate,
+ transform: transform,
+ parse: parse,
+ stringify: stringify,
+ clone: clone,
+ splitByCommand: splitByCommand
+ });
+
+ Chartist.Svg.Path.elementDescriptions = elementDescriptions;
+ Chartist.Svg.Path.join = join;
+}(window, document, Chartist));
+;/* global Chartist */
+(function (window, document, Chartist) {
+ 'use strict';
+
+ var axisUnits = {
+ x: {
+ pos: 'x',
+ len: 'width',
+ dir: 'horizontal',
+ rectStart: 'x1',
+ rectEnd: 'x2',
+ rectOffset: 'y2'
+ },
+ y: {
+ pos: 'y',
+ len: 'height',
+ dir: 'vertical',
+ rectStart: 'y2',
+ rectEnd: 'y1',
+ rectOffset: 'x1'
+ }
+ };
+
+ function Axis(units, chartRect, ticks, options) {
+ this.units = units;
+ this.counterUnits = units === axisUnits.x ? axisUnits.y : axisUnits.x;
+ this.chartRect = chartRect;
+ this.axisLength = chartRect[units.rectEnd] - chartRect[units.rectStart];
+ this.gridOffset = chartRect[units.rectOffset];
+ this.ticks = ticks;
+ this.options = options;
+ }
+
+ function createGridAndLabels(gridGroup, labelGroup, useForeignObject, chartOptions, eventEmitter) {
+ var axisOptions = chartOptions['axis' + this.units.pos.toUpperCase()];
+ var projectedValues = this.ticks.map(this.projectValue.bind(this));
+ var labelValues = this.ticks.map(axisOptions.labelInterpolationFnc);
+
+ projectedValues.forEach(function(projectedValue, index) {
+ var labelOffset = {
+ x: 0,
+ y: 0
+ };
+
+ // TODO: Find better solution for solving this problem
+ // Calculate how much space we have available for the label
+ var labelLength;
+ if(projectedValues[index + 1]) {
+ // If we still have one label ahead, we can calculate the distance to the next tick / label
+ labelLength = projectedValues[index + 1] - projectedValue;
+ } else {
+ // If we don't have a label ahead and we have only two labels in total, we just take the remaining distance to
+ // on the whole axis length. We limit that to a minimum of 30 pixel, so that labels close to the border will
+ // still be visible inside of the chart padding.
+ labelLength = Math.max(this.axisLength - projectedValue, 30);
+ }
+
+ // Skip grid lines and labels where interpolated label values are falsey (execpt for 0)
+ if(Chartist.isFalseyButZero(labelValues[index]) && labelValues[index] !== '') {
+ return;
+ }
+
+ // Transform to global coordinates using the chartRect
+ // We also need to set the label offset for the createLabel function
+ if(this.units.pos === 'x') {
+ projectedValue = this.chartRect.x1 + projectedValue;
+ labelOffset.x = chartOptions.axisX.labelOffset.x;
+
+ // If the labels should be positioned in start position (top side for vertical axis) we need to set a
+ // different offset as for positioned with end (bottom)
+ if(chartOptions.axisX.position === 'start') {
+ labelOffset.y = this.chartRect.padding.top + chartOptions.axisX.labelOffset.y + (useForeignObject ? 5 : 20);
+ } else {
+ labelOffset.y = this.chartRect.y1 + chartOptions.axisX.labelOffset.y + (useForeignObject ? 5 : 20);
+ }
+ } else {
+ projectedValue = this.chartRect.y1 - projectedValue;
+ labelOffset.y = chartOptions.axisY.labelOffset.y - (useForeignObject ? labelLength : 0);
+
+ // If the labels should be positioned in start position (left side for horizontal axis) we need to set a
+ // different offset as for positioned with end (right side)
+ if(chartOptions.axisY.position === 'start') {
+ labelOffset.x = useForeignObject ? this.chartRect.padding.left + chartOptions.axisY.labelOffset.x : this.chartRect.x1 - 10;
+ } else {
+ labelOffset.x = this.chartRect.x2 + chartOptions.axisY.labelOffset.x + 10;
+ }
+ }
+
+ if(axisOptions.showGrid) {
+ Chartist.createGrid(projectedValue, index, this, this.gridOffset, this.chartRect[this.counterUnits.len](), gridGroup, [
+ chartOptions.classNames.grid,
+ chartOptions.classNames[this.units.dir]
+ ], eventEmitter);
+ }
+
+ if(axisOptions.showLabel) {
+ Chartist.createLabel(projectedValue, labelLength, index, labelValues, this, axisOptions.offset, labelOffset, labelGroup, [
+ chartOptions.classNames.label,
+ chartOptions.classNames[this.units.dir],
+ chartOptions.classNames[axisOptions.position]
+ ], useForeignObject, eventEmitter);
+ }
+ }.bind(this));
+ }
+
+ Chartist.Axis = Chartist.Class.extend({
+ constructor: Axis,
+ createGridAndLabels: createGridAndLabels,
+ projectValue: function(value, index, data) {
+ throw new Error('Base axis can\'t be instantiated!');
+ }
+ });
+
+ Chartist.Axis.units = axisUnits;
+
+}(window, document, Chartist));
+;/**
+ * The auto scale axis uses standard linear scale projection of values along an axis. It uses order of magnitude to find a scale automatically and evaluates the available space in order to find the perfect amount of ticks for your chart.
+ * **Options**
+ * The following options are used by this axis in addition to the default axis options outlined in the axis configuration of the chart default settings.
+ * ```javascript
+ * var options = {
+ * // If high is specified then the axis will display values explicitly up to this value and the computed maximum from the data is ignored
+ * high: 100,
+ * // If low is specified then the axis will display values explicitly down to this value and the computed minimum from the data is ignored
+ * low: 0,
+ * // This option will be used when finding the right scale division settings. The amount of ticks on the scale will be determined so that as many ticks as possible will be displayed, while not violating this minimum required space (in pixel).
+ * scaleMinSpace: 20,
+ * // Can be set to true or false. If set to true, the scale will be generated with whole numbers only.
+ * onlyInteger: true,
+ * // The reference value can be used to make sure that this value will always be on the chart. This is especially useful on bipolar charts where the bipolar center always needs to be part of the chart.
+ * referenceValue: 5
+ * };
+ * ```
+ *
+ * @module Chartist.AutoScaleAxis
+ */
+/* global Chartist */
+(function (window, document, Chartist) {
+ 'use strict';
+
+ function AutoScaleAxis(axisUnit, data, chartRect, options) {
+ // Usually we calculate highLow based on the data but this can be overriden by a highLow object in the options
+ var highLow = options.highLow || Chartist.getHighLow(data.normalized, options, axisUnit.pos);
+ this.bounds = Chartist.getBounds(chartRect[axisUnit.rectEnd] - chartRect[axisUnit.rectStart], highLow, options.scaleMinSpace || 20, options.onlyInteger);
+ this.range = {
+ min: this.bounds.min,
+ max: this.bounds.max
+ };
+
+ Chartist.AutoScaleAxis.super.constructor.call(this,
+ axisUnit,
+ chartRect,
+ this.bounds.values,
+ options);
+ }
+
+ function projectValue(value) {
+ return this.axisLength * (+Chartist.getMultiValue(value, this.units.pos) - this.bounds.min) / this.bounds.range;
+ }
+
+ Chartist.AutoScaleAxis = Chartist.Axis.extend({
+ constructor: AutoScaleAxis,
+ projectValue: projectValue
+ });
+
+}(window, document, Chartist));
+;/**
+ * The fixed scale axis uses standard linear projection of values along an axis. It makes use of a divisor option to divide the range provided from the minimum and maximum value or the options high and low that will override the computed minimum and maximum.
+ * **Options**
+ * The following options are used by this axis in addition to the default axis options outlined in the axis configuration of the chart default settings.
+ * ```javascript
+ * var options = {
+ * // If high is specified then the axis will display values explicitly up to this value and the computed maximum from the data is ignored
+ * high: 100,
+ * // If low is specified then the axis will display values explicitly down to this value and the computed minimum from the data is ignored
+ * low: 0,
+ * // If specified then the value range determined from minimum to maximum (or low and high) will be divided by this number and ticks will be generated at those division points. The default divisor is 1.
+ * divisor: 4,
+ * // If ticks is explicitly set, then the axis will not compute the ticks with the divisor, but directly use the data in ticks to determine at what points on the axis a tick need to be generated.
+ * ticks: [1, 10, 20, 30]
+ * };
+ * ```
+ *
+ * @module Chartist.FixedScaleAxis
+ */
+/* global Chartist */
+(function (window, document, Chartist) {
+ 'use strict';
+
+ function FixedScaleAxis(axisUnit, data, chartRect, options) {
+ var highLow = options.highLow || Chartist.getHighLow(data.normalized, options, axisUnit.pos);
+ this.divisor = options.divisor || 1;
+ this.ticks = options.ticks || Chartist.times(this.divisor).map(function(value, index) {
+ return highLow.low + (highLow.high - highLow.low) / this.divisor * index;
+ }.bind(this));
+ this.ticks.sort(function(a, b) {
+ return a - b;
+ });
+ this.range = {
+ min: highLow.low,
+ max: highLow.high
+ };
+
+ Chartist.FixedScaleAxis.super.constructor.call(this,
+ axisUnit,
+ chartRect,
+ this.ticks,
+ options);
+
+ this.stepLength = this.axisLength / this.divisor;
+ }
+
+ function projectValue(value) {
+ return this.axisLength * (+Chartist.getMultiValue(value, this.units.pos) - this.range.min) / (this.range.max - this.range.min);
+ }
+
+ Chartist.FixedScaleAxis = Chartist.Axis.extend({
+ constructor: FixedScaleAxis,
+ projectValue: projectValue
+ });
+
+}(window, document, Chartist));
+;/**
+ * The step axis for step based charts like bar chart or step based line charts. It uses a fixed amount of ticks that will be equally distributed across the whole axis length. The projection is done using the index of the data value rather than the value itself and therefore it's only useful for distribution purpose.
+ * **Options**
+ * The following options are used by this axis in addition to the default axis options outlined in the axis configuration of the chart default settings.
+ * ```javascript
+ * var options = {
+ * // Ticks to be used to distribute across the axis length. As this axis type relies on the index of the value rather than the value, arbitrary data that can be converted to a string can be used as ticks.
+ * ticks: ['One', 'Two', 'Three'],
+ * // If set to true the full width will be used to distribute the values where the last value will be at the maximum of the axis length. If false the spaces between the ticks will be evenly distributed instead.
+ * stretch: true
+ * };
+ * ```
+ *
+ * @module Chartist.StepAxis
+ */
+/* global Chartist */
+(function (window, document, Chartist) {
+ 'use strict';
+
+ function StepAxis(axisUnit, data, chartRect, options) {
+ Chartist.StepAxis.super.constructor.call(this,
+ axisUnit,
+ chartRect,
+ options.ticks,
+ options);
+
+ this.stepLength = this.axisLength / (options.ticks.length - (options.stretch ? 1 : 0));
+ }
+
+ function projectValue(value, index) {
+ return this.stepLength * index;
+ }
+
+ Chartist.StepAxis = Chartist.Axis.extend({
+ constructor: StepAxis,
+ projectValue: projectValue
+ });
+
+}(window, document, Chartist));
+;/**
+ * The Chartist line chart can be used to draw Line or Scatter charts. If used in the browser you can access the global `Chartist` namespace where you find the `Line` function as a main entry point.
+ *
+ * For examples on how to use the line chart please check the examples of the `Chartist.Line` method.
+ *
+ * @module Chartist.Line
+ */
+/* global Chartist */
+(function(window, document, Chartist){
+ 'use strict';
+
+ /**
+ * Default options in line charts. Expand the code view to see a detailed list of options with comments.
+ *
+ * @memberof Chartist.Line
+ */
+ var defaultOptions = {
+ // Options for X-Axis
+ axisX: {
+ // The offset of the labels to the chart area
+ offset: 30,
+ // Position where labels are placed. Can be set to `start` or `end` where `start` is equivalent to left or top on vertical axis and `end` is equivalent to right or bottom on horizontal axis.
+ position: 'end',
+ // Allows you to correct label positioning on this axis by positive or negative x and y offset.
+ labelOffset: {
+ x: 0,
+ y: 0
+ },
+ // If labels should be shown or not
+ showLabel: true,
+ // If the axis grid should be drawn or not
+ showGrid: true,
+ // Interpolation function that allows you to intercept the value from the axis label
+ labelInterpolationFnc: Chartist.noop,
+ // Set the axis type to be used to project values on this axis. If not defined, Chartist.StepAxis will be used for the X-Axis, where the ticks option will be set to the labels in the data and the stretch option will be set to the global fullWidth option. This type can be changed to any axis constructor available (e.g. Chartist.FixedScaleAxis), where all axis options should be present here.
+ type: undefined
+ },
+ // Options for Y-Axis
+ axisY: {
+ // The offset of the labels to the chart area
+ offset: 40,
+ // Position where labels are placed. Can be set to `start` or `end` where `start` is equivalent to left or top on vertical axis and `end` is equivalent to right or bottom on horizontal axis.
+ position: 'start',
+ // Allows you to correct label positioning on this axis by positive or negative x and y offset.
+ labelOffset: {
+ x: 0,
+ y: 0
+ },
+ // If labels should be shown or not
+ showLabel: true,
+ // If the axis grid should be drawn or not
+ showGrid: true,
+ // Interpolation function that allows you to intercept the value from the axis label
+ labelInterpolationFnc: Chartist.noop,
+ // Set the axis type to be used to project values on this axis. If not defined, Chartist.AutoScaleAxis will be used for the Y-Axis, where the high and low options will be set to the global high and low options. This type can be changed to any axis constructor available (e.g. Chartist.FixedScaleAxis), where all axis options should be present here.
+ type: undefined,
+ // This value specifies the minimum height in pixel of the scale steps
+ scaleMinSpace: 20,
+ // Use only integer values (whole numbers) for the scale steps
+ onlyInteger: false
+ },
+ // Specify a fixed width for the chart as a string (i.e. '100px' or '50%')
+ width: undefined,
+ // Specify a fixed height for the chart as a string (i.e. '100px' or '50%')
+ height: undefined,
+ // If the line should be drawn or not
+ showLine: true,
+ // If dots should be drawn or not
+ showPoint: true,
+ // If the line chart should draw an area
+ showArea: false,
+ // The base for the area chart that will be used to close the area shape (is normally 0)
+ areaBase: 0,
+ // Specify if the lines should be smoothed. This value can be true or false where true will result in smoothing using the default smoothing interpolation function Chartist.Interpolation.cardinal and false results in Chartist.Interpolation.none. You can also choose other smoothing / interpolation functions available in the Chartist.Interpolation module, or write your own interpolation function. Check the examples for a brief description.
+ lineSmooth: true,
+ // Overriding the natural low of the chart allows you to zoom in or limit the charts lowest displayed value
+ low: undefined,
+ // Overriding the natural high of the chart allows you to zoom in or limit the charts highest displayed value
+ high: undefined,
+ // Padding of the chart drawing area to the container element and labels as a number or padding object {top: 5, right: 5, bottom: 5, left: 5}
+ chartPadding: {
+ top: 15,
+ right: 15,
+ bottom: 5,
+ left: 10
+ },
+ // When set to true, the last grid line on the x-axis is not drawn and the chart elements will expand to the full available width of the chart. For the last label to be drawn correctly you might need to add chart padding or offset the last label with a draw event handler.
+ fullWidth: false,
+ // If true the whole data is reversed including labels, the series order as well as the whole series data arrays.
+ reverseData: false,
+ // Override the class names that get used to generate the SVG structure of the chart
+ classNames: {
+ chart: 'ct-chart-line',
+ label: 'ct-label',
+ labelGroup: 'ct-labels',
+ series: 'ct-series',
+ line: 'ct-line',
+ point: 'ct-point',
+ area: 'ct-area',
+ grid: 'ct-grid',
+ gridGroup: 'ct-grids',
+ vertical: 'ct-vertical',
+ horizontal: 'ct-horizontal',
+ start: 'ct-start',
+ end: 'ct-end'
+ }
+ };
+
+ /**
+ * Creates a new chart
+ *
+ */
+ function createChart(options) {
+ this.data = Chartist.normalizeData(this.data);
+ var data = {
+ raw: this.data,
+ normalized: Chartist.getDataArray(this.data, options.reverseData, true)
+ };
+
+ // Create new svg object
+ this.svg = Chartist.createSvg(this.container, options.width, options.height, options.classNames.chart);
+ // Create groups for labels, grid and series
+ var gridGroup = this.svg.elem('g').addClass(options.classNames.gridGroup);
+ var seriesGroup = this.svg.elem('g');
+ var labelGroup = this.svg.elem('g').addClass(options.classNames.labelGroup);
+
+ var chartRect = Chartist.createChartRect(this.svg, options, defaultOptions.padding);
+ var axisX, axisY;
+
+ if(options.axisX.type === undefined) {
+ axisX = new Chartist.StepAxis(Chartist.Axis.units.x, data, chartRect, Chartist.extend({}, options.axisX, {
+ ticks: data.raw.labels,
+ stretch: options.fullWidth
+ }));
+ } else {
+ axisX = options.axisX.type.call(Chartist, Chartist.Axis.units.x, data, chartRect, options.axisX);
+ }
+
+ if(options.axisY.type === undefined) {
+ axisY = new Chartist.AutoScaleAxis(Chartist.Axis.units.y, data, chartRect, Chartist.extend({}, options.axisY, {
+ high: Chartist.isNum(options.high) ? options.high : options.axisY.high,
+ low: Chartist.isNum(options.low) ? options.low : options.axisY.low
+ }));
+ } else {
+ axisY = options.axisY.type.call(Chartist, Chartist.Axis.units.y, data, chartRect, options.axisY);
+ }
+
+ axisX.createGridAndLabels(gridGroup, labelGroup, this.supportsForeignObject, options, this.eventEmitter);
+ axisY.createGridAndLabels(gridGroup, labelGroup, this.supportsForeignObject, options, this.eventEmitter);
+
+ // Draw the series
+ data.raw.series.forEach(function(series, seriesIndex) {
+ var seriesElement = seriesGroup.elem('g');
+
+ // Write attributes to series group element. If series name or meta is undefined the attributes will not be written
+ seriesElement.attr({
+ 'ct:series-name': series.name,
+ 'ct:meta': Chartist.serialize(series.meta)
+ });
+
+ // Use series class from series data or if not set generate one
+ seriesElement.addClass([
+ options.classNames.series,
+ (series.className || options.classNames.series + '-' + Chartist.alphaNumerate(seriesIndex))
+ ].join(' '));
+
+ var pathCoordinates = [],
+ pathData = [];
+
+ data.normalized[seriesIndex].forEach(function(value, valueIndex) {
+ var p = {
+ x: chartRect.x1 + axisX.projectValue(value, valueIndex, data.normalized[seriesIndex]),
+ y: chartRect.y1 - axisY.projectValue(value, valueIndex, data.normalized[seriesIndex])
+ };
+ pathCoordinates.push(p.x, p.y);
+ pathData.push({
+ value: value,
+ valueIndex: valueIndex,
+ meta: Chartist.getMetaData(series, valueIndex)
+ });
+ }.bind(this));
+
+ var seriesOptions = {
+ lineSmooth: Chartist.getSeriesOption(series, options, 'lineSmooth'),
+ showPoint: Chartist.getSeriesOption(series, options, 'showPoint'),
+ showLine: Chartist.getSeriesOption(series, options, 'showLine'),
+ showArea: Chartist.getSeriesOption(series, options, 'showArea'),
+ areaBase: Chartist.getSeriesOption(series, options, 'areaBase')
+ };
+
+ var smoothing = typeof seriesOptions.lineSmooth === 'function' ?
+ seriesOptions.lineSmooth : (seriesOptions.lineSmooth ? Chartist.Interpolation.monotoneCubic() : Chartist.Interpolation.none());
+ // Interpolating path where pathData will be used to annotate each path element so we can trace back the original
+ // index, value and meta data
+ var path = smoothing(pathCoordinates, pathData);
+
+ // If we should show points we need to create them now to avoid secondary loop
+ // Points are drawn from the pathElements returned by the interpolation function
+ // Small offset for Firefox to render squares correctly
+ if (seriesOptions.showPoint) {
+
+ path.pathElements.forEach(function(pathElement) {
+ var point = seriesElement.elem('line', {
+ x1: pathElement.x,
+ y1: pathElement.y,
+ x2: pathElement.x + 0.01,
+ y2: pathElement.y
+ }, options.classNames.point).attr({
+ 'ct:value': [pathElement.data.value.x, pathElement.data.value.y].filter(Chartist.isNum).join(','),
+ 'ct:meta': pathElement.data.meta
+ });
+
+ this.eventEmitter.emit('draw', {
+ type: 'point',
+ value: pathElement.data.value,
+ index: pathElement.data.valueIndex,
+ meta: pathElement.data.meta,
+ series: series,
+ seriesIndex: seriesIndex,
+ axisX: axisX,
+ axisY: axisY,
+ group: seriesElement,
+ element: point,
+ x: pathElement.x,
+ y: pathElement.y
+ });
+ }.bind(this));
+ }
+
+ if(seriesOptions.showLine) {
+ var line = seriesElement.elem('path', {
+ d: path.stringify()
+ }, options.classNames.line, true);
+
+ this.eventEmitter.emit('draw', {
+ type: 'line',
+ values: data.normalized[seriesIndex],
+ path: path.clone(),
+ chartRect: chartRect,
+ index: seriesIndex,
+ series: series,
+ seriesIndex: seriesIndex,
+ axisX: axisX,
+ axisY: axisY,
+ group: seriesElement,
+ element: line
+ });
+ }
+
+ // Area currently only works with axes that support a range!
+ if(seriesOptions.showArea && axisY.range) {
+ // If areaBase is outside the chart area (< min or > max) we need to set it respectively so that
+ // the area is not drawn outside the chart area.
+ var areaBase = Math.max(Math.min(seriesOptions.areaBase, axisY.range.max), axisY.range.min);
+
+ // We project the areaBase value into screen coordinates
+ var areaBaseProjected = chartRect.y1 - axisY.projectValue(areaBase);
+
+ // In order to form the area we'll first split the path by move commands so we can chunk it up into segments
+ path.splitByCommand('M').filter(function onlySolidSegments(pathSegment) {
+ // We filter only "solid" segments that contain more than one point. Otherwise there's no need for an area
+ return pathSegment.pathElements.length > 1;
+ }).map(function convertToArea(solidPathSegments) {
+ // Receiving the filtered solid path segments we can now convert those segments into fill areas
+ var firstElement = solidPathSegments.pathElements[0];
+ var lastElement = solidPathSegments.pathElements[solidPathSegments.pathElements.length - 1];
+
+ // Cloning the solid path segment with closing option and removing the first move command from the clone
+ // We then insert a new move that should start at the area base and draw a straight line up or down
+ // at the end of the path we add an additional straight line to the projected area base value
+ // As the closing option is set our path will be automatically closed
+ return solidPathSegments.clone(true)
+ .position(0)
+ .remove(1)
+ .move(firstElement.x, areaBaseProjected)
+ .line(firstElement.x, firstElement.y)
+ .position(solidPathSegments.pathElements.length + 1)
+ .line(lastElement.x, areaBaseProjected);
+
+ }).forEach(function createArea(areaPath) {
+ // For each of our newly created area paths, we'll now create path elements by stringifying our path objects
+ // and adding the created DOM elements to the correct series group
+ var area = seriesElement.elem('path', {
+ d: areaPath.stringify()
+ }, options.classNames.area, true);
+
+ // Emit an event for each area that was drawn
+ this.eventEmitter.emit('draw', {
+ type: 'area',
+ values: data.normalized[seriesIndex],
+ path: areaPath.clone(),
+ series: series,
+ seriesIndex: seriesIndex,
+ axisX: axisX,
+ axisY: axisY,
+ chartRect: chartRect,
+ index: seriesIndex,
+ group: seriesElement,
+ element: area
+ });
+ }.bind(this));
+ }
+ }.bind(this));
+
+ this.eventEmitter.emit('created', {
+ bounds: axisY.bounds,
+ chartRect: chartRect,
+ axisX: axisX,
+ axisY: axisY,
+ svg: this.svg,
+ options: options
+ });
+ }
+
+ /**
+ * This method creates a new line chart.
+ *
+ * @memberof Chartist.Line
+ * @param {String|Node} query A selector query string or directly a DOM element
+ * @param {Object} data The data object that needs to consist of a labels and a series array
+ * @param {Object} [options] The options object with options that override the default options. Check the examples for a detailed list.
+ * @param {Array} [responsiveOptions] Specify an array of responsive option arrays which are a media query and options object pair => [[mediaQueryString, optionsObject],[more...]]
+ * @return {Object} An object which exposes the API for the created chart
+ *
+ * @example
+ * // Create a simple line chart
+ * var data = {
+ * // A labels array that can contain any sort of values
+ * labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],
+ * // Our series array that contains series objects or in this case series data arrays
+ * series: [
+ * [5, 2, 4, 2, 0]
+ * ]
+ * };
+ *
+ * // As options we currently only set a static size of 300x200 px
+ * var options = {
+ * width: '300px',
+ * height: '200px'
+ * };
+ *
+ * // In the global name space Chartist we call the Line function to initialize a line chart. As a first parameter we pass in a selector where we would like to get our chart created. Second parameter is the actual data object and as a third parameter we pass in our options
+ * new Chartist.Line('.ct-chart', data, options);
+ *
+ * @example
+ * // Use specific interpolation function with configuration from the Chartist.Interpolation module
+ *
+ * var chart = new Chartist.Line('.ct-chart', {
+ * labels: [1, 2, 3, 4, 5],
+ * series: [
+ * [1, 1, 8, 1, 7]
+ * ]
+ * }, {
+ * lineSmooth: Chartist.Interpolation.cardinal({
+ * tension: 0.2
+ * })
+ * });
+ *
+ * @example
+ * // Create a line chart with responsive options
+ *
+ * var data = {
+ * // A labels array that can contain any sort of values
+ * labels: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
+ * // Our series array that contains series objects or in this case series data arrays
+ * series: [
+ * [5, 2, 4, 2, 0]
+ * ]
+ * };
+ *
+ * // In addition to the regular options we specify responsive option overrides that will override the default configutation based on the matching media queries.
+ * var responsiveOptions = [
+ * ['screen and (min-width: 641px) and (max-width: 1024px)', {
+ * showPoint: false,
+ * axisX: {
+ * labelInterpolationFnc: function(value) {
+ * // Will return Mon, Tue, Wed etc. on medium screens
+ * return value.slice(0, 3);
+ * }
+ * }
+ * }],
+ * ['screen and (max-width: 640px)', {
+ * showLine: false,
+ * axisX: {
+ * labelInterpolationFnc: function(value) {
+ * // Will return M, T, W etc. on small screens
+ * return value[0];
+ * }
+ * }
+ * }]
+ * ];
+ *
+ * new Chartist.Line('.ct-chart', data, null, responsiveOptions);
+ *
+ */
+ function Line(query, data, options, responsiveOptions) {
+ Chartist.Line.super.constructor.call(this,
+ query,
+ data,
+ defaultOptions,
+ Chartist.extend({}, defaultOptions, options),
+ responsiveOptions);
+ }
+
+ // Creating line chart type in Chartist namespace
+ Chartist.Line = Chartist.Base.extend({
+ constructor: Line,
+ createChart: createChart
+ });
+
+}(window, document, Chartist));
+;/**
+ * The bar chart module of Chartist that can be used to draw unipolar or bipolar bar and grouped bar charts.
+ *
+ * @module Chartist.Bar
+ */
+/* global Chartist */
+(function(window, document, Chartist){
+ 'use strict';
+
+ /**
+ * Default options in bar charts. Expand the code view to see a detailed list of options with comments.
+ *
+ * @memberof Chartist.Bar
+ */
+ var defaultOptions = {
+ // Options for X-Axis
+ axisX: {
+ // The offset of the chart drawing area to the border of the container
+ offset: 30,
+ // Position where labels are placed. Can be set to `start` or `end` where `start` is equivalent to left or top on vertical axis and `end` is equivalent to right or bottom on horizontal axis.
+ position: 'end',
+ // Allows you to correct label positioning on this axis by positive or negative x and y offset.
+ labelOffset: {
+ x: 0,
+ y: 0
+ },
+ // If labels should be shown or not
+ showLabel: true,
+ // If the axis grid should be drawn or not
+ showGrid: true,
+ // Interpolation function that allows you to intercept the value from the axis label
+ labelInterpolationFnc: Chartist.noop,
+ // This value specifies the minimum width in pixel of the scale steps
+ scaleMinSpace: 30,
+ // Use only integer values (whole numbers) for the scale steps
+ onlyInteger: false
+ },
+ // Options for Y-Axis
+ axisY: {
+ // The offset of the chart drawing area to the border of the container
+ offset: 40,
+ // Position where labels are placed. Can be set to `start` or `end` where `start` is equivalent to left or top on vertical axis and `end` is equivalent to right or bottom on horizontal axis.
+ position: 'start',
+ // Allows you to correct label positioning on this axis by positive or negative x and y offset.
+ labelOffset: {
+ x: 0,
+ y: 0
+ },
+ // If labels should be shown or not
+ showLabel: true,
+ // If the axis grid should be drawn or not
+ showGrid: true,
+ // Interpolation function that allows you to intercept the value from the axis label
+ labelInterpolationFnc: Chartist.noop,
+ // This value specifies the minimum height in pixel of the scale steps
+ scaleMinSpace: 20,
+ // Use only integer values (whole numbers) for the scale steps
+ onlyInteger: false
+ },
+ // Specify a fixed width for the chart as a string (i.e. '100px' or '50%')
+ width: undefined,
+ // Specify a fixed height for the chart as a string (i.e. '100px' or '50%')
+ height: undefined,
+ // Overriding the natural high of the chart allows you to zoom in or limit the charts highest displayed value
+ high: undefined,
+ // Overriding the natural low of the chart allows you to zoom in or limit the charts lowest displayed value
+ low: undefined,
+ // Padding of the chart drawing area to the container element and labels as a number or padding object {top: 5, right: 5, bottom: 5, left: 5}
+ chartPadding: {
+ top: 15,
+ right: 15,
+ bottom: 5,
+ left: 10
+ },
+ // Specify the distance in pixel of bars in a group
+ seriesBarDistance: 15,
+ // If set to true this property will cause the series bars to be stacked. Check the `stackMode` option for further stacking options.
+ stackBars: false,
+ // If set to 'overlap' this property will force the stacked bars to draw from the zero line.
+ // If set to 'accumulate' this property will form a total for each series point. This will also influence the y-axis and the overall bounds of the chart. In stacked mode the seriesBarDistance property will have no effect.
+ stackMode: 'accumulate',
+ // Inverts the axes of the bar chart in order to draw a horizontal bar chart. Be aware that you also need to invert your axis settings as the Y Axis will now display the labels and the X Axis the values.
+ horizontalBars: false,
+ // If set to true then each bar will represent a series and the data array is expected to be a one dimensional array of data values rather than a series array of series. This is useful if the bar chart should represent a profile rather than some data over time.
+ distributeSeries: false,
+ // If true the whole data is reversed including labels, the series order as well as the whole series data arrays.
+ reverseData: false,
+ // Override the class names that get used to generate the SVG structure of the chart
+ classNames: {
+ chart: 'ct-chart-bar',
+ horizontalBars: 'ct-horizontal-bars',
+ label: 'ct-label',
+ labelGroup: 'ct-labels',
+ series: 'ct-series',
+ bar: 'ct-bar',
+ grid: 'ct-grid',
+ gridGroup: 'ct-grids',
+ vertical: 'ct-vertical',
+ horizontal: 'ct-horizontal',
+ start: 'ct-start',
+ end: 'ct-end'
+ }
+ };
+
+ /**
+ * Creates a new chart
+ *
+ */
+ function createChart(options) {
+ this.data = Chartist.normalizeData(this.data);
+ var data = {
+ raw: this.data,
+ normalized: options.distributeSeries ? Chartist.getDataArray(this.data, options.reverseData, options.horizontalBars ? 'x' : 'y').map(function(value) {
+ return [value];
+ }) : Chartist.getDataArray(this.data, options.reverseData, options.horizontalBars ? 'x' : 'y')
+ };
+
+ var highLow;
+
+ // Create new svg element
+ this.svg = Chartist.createSvg(
+ this.container,
+ options.width,
+ options.height,
+ options.classNames.chart + (options.horizontalBars ? ' ' + options.classNames.horizontalBars : '')
+ );
+
+ // Drawing groups in correct order
+ var gridGroup = this.svg.elem('g').addClass(options.classNames.gridGroup);
+ var seriesGroup = this.svg.elem('g');
+ var labelGroup = this.svg.elem('g').addClass(options.classNames.labelGroup);
+
+ if(options.stackBars && data.normalized.length !== 0) {
+ // If stacked bars we need to calculate the high low from stacked values from each series
+ var serialSums = Chartist.serialMap(data.normalized, function serialSums() {
+ return Array.prototype.slice.call(arguments).map(function(value) {
+ return value;
+ }).reduce(function(prev, curr) {
+ return {
+ x: prev.x + (curr && curr.x) || 0,
+ y: prev.y + (curr && curr.y) || 0
+ };
+ }, {x: 0, y: 0});
+ });
+
+ highLow = Chartist.getHighLow([serialSums], Chartist.extend({}, options, {
+ referenceValue: 0
+ }), options.horizontalBars ? 'x' : 'y');
+ } else {
+ highLow = Chartist.getHighLow(data.normalized, Chartist.extend({}, options, {
+ referenceValue: 0
+ }), options.horizontalBars ? 'x' : 'y');
+ }
+ // Overrides of high / low from settings
+ highLow.high = +options.high || (options.high === 0 ? 0 : highLow.high);
+ highLow.low = +options.low || (options.low === 0 ? 0 : highLow.low);
+
+ var chartRect = Chartist.createChartRect(this.svg, options, defaultOptions.padding);
+
+ var valueAxis,
+ labelAxisTicks,
+ labelAxis,
+ axisX,
+ axisY;
+
+ // We need to set step count based on some options combinations
+ if(options.distributeSeries && options.stackBars) {
+ // If distributed series are enabled and bars need to be stacked, we'll only have one bar and therefore should
+ // use only the first label for the step axis
+ labelAxisTicks = data.raw.labels.slice(0, 1);
+ } else {
+ // If distributed series are enabled but stacked bars aren't, we should use the series labels
+ // If we are drawing a regular bar chart with two dimensional series data, we just use the labels array
+ // as the bars are normalized
+ labelAxisTicks = data.raw.labels;
+ }
+
+ // Set labelAxis and valueAxis based on the horizontalBars setting. This setting will flip the axes if necessary.
+ if(options.horizontalBars) {
+ if(options.axisX.type === undefined) {
+ valueAxis = axisX = new Chartist.AutoScaleAxis(Chartist.Axis.units.x, data, chartRect, Chartist.extend({}, options.axisX, {
+ highLow: highLow,
+ referenceValue: 0
+ }));
+ } else {
+ valueAxis = axisX = options.axisX.type.call(Chartist, Chartist.Axis.units.x, data, chartRect, Chartist.extend({}, options.axisX, {
+ highLow: highLow,
+ referenceValue: 0
+ }));
+ }
+
+ if(options.axisY.type === undefined) {
+ labelAxis = axisY = new Chartist.StepAxis(Chartist.Axis.units.y, data, chartRect, {
+ ticks: labelAxisTicks
+ });
+ } else {
+ labelAxis = axisY = options.axisY.type.call(Chartist, Chartist.Axis.units.y, data, chartRect, options.axisY);
+ }
+ } else {
+ if(options.axisX.type === undefined) {
+ labelAxis = axisX = new Chartist.StepAxis(Chartist.Axis.units.x, data, chartRect, {
+ ticks: labelAxisTicks
+ });
+ } else {
+ labelAxis = axisX = options.axisX.type.call(Chartist, Chartist.Axis.units.x, data, chartRect, options.axisX);
+ }
+
+ if(options.axisY.type === undefined) {
+ valueAxis = axisY = new Chartist.AutoScaleAxis(Chartist.Axis.units.y, data, chartRect, Chartist.extend({}, options.axisY, {
+ highLow: highLow,
+ referenceValue: 0
+ }));
+ } else {
+ valueAxis = axisY = options.axisY.type.call(Chartist, Chartist.Axis.units.y, data, chartRect, Chartist.extend({}, options.axisY, {
+ highLow: highLow,
+ referenceValue: 0
+ }));
+ }
+ }
+
+ // Projected 0 point
+ var zeroPoint = options.horizontalBars ? (chartRect.x1 + valueAxis.projectValue(0)) : (chartRect.y1 - valueAxis.projectValue(0));
+ // Used to track the screen coordinates of stacked bars
+ var stackedBarValues = [];
+
+ labelAxis.createGridAndLabels(gridGroup, labelGroup, this.supportsForeignObject, options, this.eventEmitter);
+ valueAxis.createGridAndLabels(gridGroup, labelGroup, this.supportsForeignObject, options, this.eventEmitter);
+
+ // Draw the series
+ data.raw.series.forEach(function(series, seriesIndex) {
+ // Calculating bi-polar value of index for seriesOffset. For i = 0..4 biPol will be -1.5, -0.5, 0.5, 1.5 etc.
+ var biPol = seriesIndex - (data.raw.series.length - 1) / 2;
+ // Half of the period width between vertical grid lines used to position bars
+ var periodHalfLength;
+ // Current series SVG element
+ var seriesElement;
+
+ // We need to set periodHalfLength based on some options combinations
+ if(options.distributeSeries && !options.stackBars) {
+ // If distributed series are enabled but stacked bars aren't, we need to use the length of the normaizedData array
+ // which is the series count and divide by 2
+ periodHalfLength = labelAxis.axisLength / data.normalized.length / 2;
+ } else if(options.distributeSeries && options.stackBars) {
+ // If distributed series and stacked bars are enabled we'll only get one bar so we should just divide the axis
+ // length by 2
+ periodHalfLength = labelAxis.axisLength / 2;
+ } else {
+ // On regular bar charts we should just use the series length
+ periodHalfLength = labelAxis.axisLength / data.normalized[seriesIndex].length / 2;
+ }
+
+ // Adding the series group to the series element
+ seriesElement = seriesGroup.elem('g');
+
+ // Write attributes to series group element. If series name or meta is undefined the attributes will not be written
+ seriesElement.attr({
+ 'ct:series-name': series.name,
+ 'ct:meta': Chartist.serialize(series.meta)
+ });
+
+ // Use series class from series data or if not set generate one
+ seriesElement.addClass([
+ options.classNames.series,
+ (series.className || options.classNames.series + '-' + Chartist.alphaNumerate(seriesIndex))
+ ].join(' '));
+
+ data.normalized[seriesIndex].forEach(function(value, valueIndex) {
+ var projected,
+ bar,
+ previousStack,
+ labelAxisValueIndex;
+
+ // We need to set labelAxisValueIndex based on some options combinations
+ if(options.distributeSeries && !options.stackBars) {
+ // If distributed series are enabled but stacked bars aren't, we can use the seriesIndex for later projection
+ // on the step axis for label positioning
+ labelAxisValueIndex = seriesIndex;
+ } else if(options.distributeSeries && options.stackBars) {
+ // If distributed series and stacked bars are enabled, we will only get one bar and therefore always use
+ // 0 for projection on the label step axis
+ labelAxisValueIndex = 0;
+ } else {
+ // On regular bar charts we just use the value index to project on the label step axis
+ labelAxisValueIndex = valueIndex;
+ }
+
+ // We need to transform coordinates differently based on the chart layout
+ if(options.horizontalBars) {
+ projected = {
+ x: chartRect.x1 + valueAxis.projectValue(value && value.x ? value.x : 0, valueIndex, data.normalized[seriesIndex]),
+ y: chartRect.y1 - labelAxis.projectValue(value && value.y ? value.y : 0, labelAxisValueIndex, data.normalized[seriesIndex])
+ };
+ } else {
+ projected = {
+ x: chartRect.x1 + labelAxis.projectValue(value && value.x ? value.x : 0, labelAxisValueIndex, data.normalized[seriesIndex]),
+ y: chartRect.y1 - valueAxis.projectValue(value && value.y ? value.y : 0, valueIndex, data.normalized[seriesIndex])
+ }
+ }
+
+ // If the label axis is a step based axis we will offset the bar into the middle of between two steps using
+ // the periodHalfLength value. Also we do arrange the different series so that they align up to each other using
+ // the seriesBarDistance. If we don't have a step axis, the bar positions can be chosen freely so we should not
+ // add any automated positioning.
+ if(labelAxis instanceof Chartist.StepAxis) {
+ // Offset to center bar between grid lines, but only if the step axis is not stretched
+ if(!labelAxis.options.stretch) {
+ projected[labelAxis.units.pos] += periodHalfLength * (options.horizontalBars ? -1 : 1);
+ }
+ // Using bi-polar offset for multiple series if no stacked bars or series distribution is used
+ projected[labelAxis.units.pos] += (options.stackBars || options.distributeSeries) ? 0 : biPol * options.seriesBarDistance * (options.horizontalBars ? -1 : 1);
+ }
+
+ // Enter value in stacked bar values used to remember previous screen value for stacking up bars
+ previousStack = stackedBarValues[valueIndex] || zeroPoint;
+ stackedBarValues[valueIndex] = previousStack - (zeroPoint - projected[labelAxis.counterUnits.pos]);
+
+ // Skip if value is undefined
+ if(value === undefined) {
+ return;
+ }
+
+ var positions = {};
+ positions[labelAxis.units.pos + '1'] = projected[labelAxis.units.pos];
+ positions[labelAxis.units.pos + '2'] = projected[labelAxis.units.pos];
+
+ if(options.stackBars && (options.stackMode === 'accumulate' || !options.stackMode)) {
+ // Stack mode: accumulate (default)
+ // If bars are stacked we use the stackedBarValues reference and otherwise base all bars off the zero line
+ // We want backwards compatibility, so the expected fallback without the 'stackMode' option
+ // to be the original behaviour (accumulate)
+ positions[labelAxis.counterUnits.pos + '1'] = previousStack;
+ positions[labelAxis.counterUnits.pos + '2'] = stackedBarValues[valueIndex];
+ } else {
+ // Draw from the zero line normally
+ // This is also the same code for Stack mode: overlap
+ positions[labelAxis.counterUnits.pos + '1'] = zeroPoint;
+ positions[labelAxis.counterUnits.pos + '2'] = projected[labelAxis.counterUnits.pos];
+ }
+
+ // Limit x and y so that they are within the chart rect
+ positions.x1 = Math.min(Math.max(positions.x1, chartRect.x1), chartRect.x2);
+ positions.x2 = Math.min(Math.max(positions.x2, chartRect.x1), chartRect.x2);
+ positions.y1 = Math.min(Math.max(positions.y1, chartRect.y2), chartRect.y1);
+ positions.y2 = Math.min(Math.max(positions.y2, chartRect.y2), chartRect.y1);
+
+ // Create bar element
+ bar = seriesElement.elem('line', positions, options.classNames.bar).attr({
+ 'ct:value': [value.x, value.y].filter(Chartist.isNum).join(','),
+ 'ct:meta': Chartist.getMetaData(series, valueIndex)
+ });
+
+ this.eventEmitter.emit('draw', Chartist.extend({
+ type: 'bar',
+ value: value,
+ index: valueIndex,
+ meta: Chartist.getMetaData(series, valueIndex),
+ series: series,
+ seriesIndex: seriesIndex,
+ axisX: axisX,
+ axisY: axisY,
+ chartRect: chartRect,
+ group: seriesElement,
+ element: bar
+ }, positions));
+ }.bind(this));
+ }.bind(this));
+
+ this.eventEmitter.emit('created', {
+ bounds: valueAxis.bounds,
+ chartRect: chartRect,
+ axisX: axisX,
+ axisY: axisY,
+ svg: this.svg,
+ options: options
+ });
+ }
+
+ /**
+ * This method creates a new bar chart and returns API object that you can use for later changes.
+ *
+ * @memberof Chartist.Bar
+ * @param {String|Node} query A selector query string or directly a DOM element
+ * @param {Object} data The data object that needs to consist of a labels and a series array
+ * @param {Object} [options] The options object with options that override the default options. Check the examples for a detailed list.
+ * @param {Array} [responsiveOptions] Specify an array of responsive option arrays which are a media query and options object pair => [[mediaQueryString, optionsObject],[more...]]
+ * @return {Object} An object which exposes the API for the created chart
+ *
+ * @example
+ * // Create a simple bar chart
+ * var data = {
+ * labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],
+ * series: [
+ * [5, 2, 4, 2, 0]
+ * ]
+ * };
+ *
+ * // In the global name space Chartist we call the Bar function to initialize a bar chart. As a first parameter we pass in a selector where we would like to get our chart created and as a second parameter we pass our data object.
+ * new Chartist.Bar('.ct-chart', data);
+ *
+ * @example
+ * // This example creates a bipolar grouped bar chart where the boundaries are limitted to -10 and 10
+ * new Chartist.Bar('.ct-chart', {
+ * labels: [1, 2, 3, 4, 5, 6, 7],
+ * series: [
+ * [1, 3, 2, -5, -3, 1, -6],
+ * [-5, -2, -4, -1, 2, -3, 1]
+ * ]
+ * }, {
+ * seriesBarDistance: 12,
+ * low: -10,
+ * high: 10
+ * });
+ *
+ */
+ function Bar(query, data, options, responsiveOptions) {
+ Chartist.Bar.super.constructor.call(this,
+ query,
+ data,
+ defaultOptions,
+ Chartist.extend({}, defaultOptions, options),
+ responsiveOptions);
+ }
+
+ // Creating bar chart type in Chartist namespace
+ Chartist.Bar = Chartist.Base.extend({
+ constructor: Bar,
+ createChart: createChart
+ });
+
+}(window, document, Chartist));
+;/**
+ * The pie chart module of Chartist that can be used to draw pie, donut or gauge charts
+ *
+ * @module Chartist.Pie
+ */
+/* global Chartist */
+(function(window, document, Chartist) {
+ 'use strict';
+
+ /**
+ * Default options in line charts. Expand the code view to see a detailed list of options with comments.
+ *
+ * @memberof Chartist.Pie
+ */
+ var defaultOptions = {
+ // Specify a fixed width for the chart as a string (i.e. '100px' or '50%')
+ width: undefined,
+ // Specify a fixed height for the chart as a string (i.e. '100px' or '50%')
+ height: undefined,
+ // Padding of the chart drawing area to the container element and labels as a number or padding object {top: 5, right: 5, bottom: 5, left: 5}
+ chartPadding: 5,
+ // Override the class names that are used to generate the SVG structure of the chart
+ classNames: {
+ chartPie: 'ct-chart-pie',
+ chartDonut: 'ct-chart-donut',
+ series: 'ct-series',
+ slicePie: 'ct-slice-pie',
+ sliceDonut: 'ct-slice-donut',
+ label: 'ct-label'
+ },
+ // The start angle of the pie chart in degrees where 0 points north. A higher value offsets the start angle clockwise.
+ startAngle: 0,
+ // An optional total you can specify. By specifying a total value, the sum of the values in the series must be this total in order to draw a full pie. You can use this parameter to draw only parts of a pie or gauge charts.
+ total: undefined,
+ // If specified the donut CSS classes will be used and strokes will be drawn instead of pie slices.
+ donut: false,
+ // Specify the donut stroke width, currently done in javascript for convenience. May move to CSS styles in the future.
+ // This option can be set as number or string to specify a relative width (i.e. 100 or '30%').
+ donutWidth: 60,
+ // If a label should be shown or not
+ showLabel: true,
+ // Label position offset from the standard position which is half distance of the radius. This value can be either positive or negative. Positive values will position the label away from the center.
+ labelOffset: 0,
+ // This option can be set to 'inside', 'outside' or 'center'. Positioned with 'inside' the labels will be placed on half the distance of the radius to the border of the Pie by respecting the 'labelOffset'. The 'outside' option will place the labels at the border of the pie and 'center' will place the labels in the absolute center point of the chart. The 'center' option only makes sense in conjunction with the 'labelOffset' option.
+ labelPosition: 'inside',
+ // An interpolation function for the label value
+ labelInterpolationFnc: Chartist.noop,
+ // Label direction can be 'neutral', 'explode' or 'implode'. The labels anchor will be positioned based on those settings as well as the fact if the labels are on the right or left side of the center of the chart. Usually explode is useful when labels are positioned far away from the center.
+ labelDirection: 'neutral',
+ // If true the whole data is reversed including labels, the series order as well as the whole series data arrays.
+ reverseData: false,
+ // If true empty values will be ignored to avoid drawing unncessary slices and labels
+ ignoreEmptyValues: false
+ };
+
+ /**
+ * Determines SVG anchor position based on direction and center parameter
+ *
+ * @param center
+ * @param label
+ * @param direction
+ * @return {string}
+ */
+ function determineAnchorPosition(center, label, direction) {
+ var toTheRight = label.x > center.x;
+
+ if(toTheRight && direction === 'explode' ||
+ !toTheRight && direction === 'implode') {
+ return 'start';
+ } else if(toTheRight && direction === 'implode' ||
+ !toTheRight && direction === 'explode') {
+ return 'end';
+ } else {
+ return 'middle';
+ }
+ }
+
+ /**
+ * Creates the pie chart
+ *
+ * @param options
+ */
+ function createChart(options) {
+ this.data = Chartist.normalizeData(this.data);
+ var seriesGroups = [],
+ labelsGroup,
+ chartRect,
+ radius,
+ labelRadius,
+ totalDataSum,
+ startAngle = options.startAngle,
+ dataArray = Chartist.getDataArray(this.data, options.reverseData);
+
+ // Create SVG.js draw
+ this.svg = Chartist.createSvg(this.container, options.width, options.height,options.donut ? options.classNames.chartDonut : options.classNames.chartPie);
+ // Calculate charting rect
+ chartRect = Chartist.createChartRect(this.svg, options, defaultOptions.padding);
+ // Get biggest circle radius possible within chartRect
+ radius = Math.min(chartRect.width() / 2, chartRect.height() / 2);
+ // Calculate total of all series to get reference value or use total reference from optional options
+ totalDataSum = options.total || dataArray.reduce(function(previousValue, currentValue) {
+ return previousValue + currentValue;
+ }, 0);
+
+ var donutWidth = Chartist.quantity(options.donutWidth);
+ if (donutWidth.unit === '%') {
+ donutWidth.value *= radius / 100;
+ }
+
+ // If this is a donut chart we need to adjust our radius to enable strokes to be drawn inside
+ // Unfortunately this is not possible with the current SVG Spec
+ // See this proposal for more details: http://lists.w3.org/Archives/Public/www-svg/2003Oct/0000.html
+ radius -= options.donut ? donutWidth.value / 2 : 0;
+
+ // If labelPosition is set to `outside` or a donut chart is drawn then the label position is at the radius,
+ // if regular pie chart it's half of the radius
+ if(options.labelPosition === 'outside' || options.donut) {
+ labelRadius = radius;
+ } else if(options.labelPosition === 'center') {
+ // If labelPosition is center we start with 0 and will later wait for the labelOffset
+ labelRadius = 0;
+ } else {
+ // Default option is 'inside' where we use half the radius so the label will be placed in the center of the pie
+ // slice
+ labelRadius = radius / 2;
+ }
+ // Add the offset to the labelRadius where a negative offset means closed to the center of the chart
+ labelRadius += options.labelOffset;
+
+ // Calculate end angle based on total sum and current data value and offset with padding
+ var center = {
+ x: chartRect.x1 + chartRect.width() / 2,
+ y: chartRect.y2 + chartRect.height() / 2
+ };
+
+ // Check if there is only one non-zero value in the series array.
+ var hasSingleValInSeries = this.data.series.filter(function(val) {
+ return val.hasOwnProperty('value') ? val.value !== 0 : val !== 0;
+ }).length === 1;
+
+ //if we need to show labels we create the label group now
+ if(options.showLabel) {
+ labelsGroup = this.svg.elem('g', null, null, true);
+ }
+
+ // Draw the series
+ // initialize series groups
+ for (var i = 0; i < this.data.series.length; i++) {
+ // If current value is zero and we are ignoring empty values then skip to next value
+ if (dataArray[i] === 0 && options.ignoreEmptyValues) continue;
+
+ var series = this.data.series[i];
+ seriesGroups[i] = this.svg.elem('g', null, null, true);
+
+ // If the series is an object and contains a name or meta data we add a custom attribute
+ seriesGroups[i].attr({
+ 'ct:series-name': series.name
+ });
+
+ // Use series class from series data or if not set generate one
+ seriesGroups[i].addClass([
+ options.classNames.series,
+ (series.className || options.classNames.series + '-' + Chartist.alphaNumerate(i))
+ ].join(' '));
+
+ var endAngle = startAngle + dataArray[i] / totalDataSum * 360;
+
+ // Use slight offset so there are no transparent hairline issues
+ var overlappigStartAngle = Math.max(0, startAngle - (i === 0 || hasSingleValInSeries ? 0 : 0.2));
+
+ // If we need to draw the arc for all 360 degrees we need to add a hack where we close the circle
+ // with Z and use 359.99 degrees
+ if(endAngle - overlappigStartAngle >= 359.99) {
+ endAngle = overlappigStartAngle + 359.99;
+ }
+
+ var start = Chartist.polarToCartesian(center.x, center.y, radius, overlappigStartAngle),
+ end = Chartist.polarToCartesian(center.x, center.y, radius, endAngle);
+
+ // Create a new path element for the pie chart. If this isn't a donut chart we should close the path for a correct stroke
+ var path = new Chartist.Svg.Path(!options.donut)
+ .move(end.x, end.y)
+ .arc(radius, radius, 0, endAngle - startAngle > 180, 0, start.x, start.y);
+
+ // If regular pie chart (no donut) we add a line to the center of the circle for completing the pie
+ if(!options.donut) {
+ path.line(center.x, center.y);
+ }
+
+ // Create the SVG path
+ // If this is a donut chart we add the donut class, otherwise just a regular slice
+ var pathElement = seriesGroups[i].elem('path', {
+ d: path.stringify()
+ }, options.donut ? options.classNames.sliceDonut : options.classNames.slicePie);
+
+ // Adding the pie series value to the path
+ pathElement.attr({
+ 'ct:value': dataArray[i],
+ 'ct:meta': Chartist.serialize(series.meta)
+ });
+
+ // If this is a donut, we add the stroke-width as style attribute
+ if(options.donut) {
+ pathElement.attr({
+ 'style': 'stroke-width: ' + donutWidth.value + 'px'
+ });
+ }
+
+ // Fire off draw event
+ this.eventEmitter.emit('draw', {
+ type: 'slice',
+ value: dataArray[i],
+ totalDataSum: totalDataSum,
+ index: i,
+ meta: series.meta,
+ series: series,
+ group: seriesGroups[i],
+ element: pathElement,
+ path: path.clone(),
+ center: center,
+ radius: radius,
+ startAngle: startAngle,
+ endAngle: endAngle
+ });
+
+ // If we need to show labels we need to add the label for this slice now
+ if(options.showLabel) {
+ // Position at the labelRadius distance from center and between start and end angle
+ var labelPosition = Chartist.polarToCartesian(center.x, center.y, labelRadius, startAngle + (endAngle - startAngle) / 2),
+ interpolatedValue = options.labelInterpolationFnc(this.data.labels && !Chartist.isFalseyButZero(this.data.labels[i]) ? this.data.labels[i] : dataArray[i], i);
+
+ if(interpolatedValue || interpolatedValue === 0) {
+ var labelElement = labelsGroup.elem('text', {
+ dx: labelPosition.x,
+ dy: labelPosition.y,
+ 'text-anchor': determineAnchorPosition(center, labelPosition, options.labelDirection)
+ }, options.classNames.label).text('' + interpolatedValue);
+
+ // Fire off draw event
+ this.eventEmitter.emit('draw', {
+ type: 'label',
+ index: i,
+ group: labelsGroup,
+ element: labelElement,
+ text: '' + interpolatedValue,
+ x: labelPosition.x,
+ y: labelPosition.y
+ });
+ }
+ }
+
+ // Set next startAngle to current endAngle.
+ // (except for last slice)
+ startAngle = endAngle;
+ }
+
+ this.eventEmitter.emit('created', {
+ chartRect: chartRect,
+ svg: this.svg,
+ options: options
+ });
+ }
+
+ /**
+ * This method creates a new pie chart and returns an object that can be used to redraw the chart.
+ *
+ * @memberof Chartist.Pie
+ * @param {String|Node} query A selector query string or directly a DOM element
+ * @param {Object} data The data object in the pie chart needs to have a series property with a one dimensional data array. The values will be normalized against each other and don't necessarily need to be in percentage. The series property can also be an array of value objects that contain a value property and a className property to override the CSS class name for the series group.
+ * @param {Object} [options] The options object with options that override the default options. Check the examples for a detailed list.
+ * @param {Array} [responsiveOptions] Specify an array of responsive option arrays which are a media query and options object pair => [[mediaQueryString, optionsObject],[more...]]
+ * @return {Object} An object with a version and an update method to manually redraw the chart
+ *
+ * @example
+ * // Simple pie chart example with four series
+ * new Chartist.Pie('.ct-chart', {
+ * series: [10, 2, 4, 3]
+ * });
+ *
+ * @example
+ * // Drawing a donut chart
+ * new Chartist.Pie('.ct-chart', {
+ * series: [10, 2, 4, 3]
+ * }, {
+ * donut: true
+ * });
+ *
+ * @example
+ * // Using donut, startAngle and total to draw a gauge chart
+ * new Chartist.Pie('.ct-chart', {
+ * series: [20, 10, 30, 40]
+ * }, {
+ * donut: true,
+ * donutWidth: 20,
+ * startAngle: 270,
+ * total: 200
+ * });
+ *
+ * @example
+ * // Drawing a pie chart with padding and labels that are outside the pie
+ * new Chartist.Pie('.ct-chart', {
+ * series: [20, 10, 30, 40]
+ * }, {
+ * chartPadding: 30,
+ * labelOffset: 50,
+ * labelDirection: 'explode'
+ * });
+ *
+ * @example
+ * // Overriding the class names for individual series as well as a name and meta data.
+ * // The name will be written as ct:series-name attribute and the meta data will be serialized and written
+ * // to a ct:meta attribute.
+ * new Chartist.Pie('.ct-chart', {
+ * series: [{
+ * value: 20,
+ * name: 'Series 1',
+ * className: 'my-custom-class-one',
+ * meta: 'Meta One'
+ * }, {
+ * value: 10,
+ * name: 'Series 2',
+ * className: 'my-custom-class-two',
+ * meta: 'Meta Two'
+ * }, {
+ * value: 70,
+ * name: 'Series 3',
+ * className: 'my-custom-class-three',
+ * meta: 'Meta Three'
+ * }]
+ * });
+ */
+ function Pie(query, data, options, responsiveOptions) {
+ Chartist.Pie.super.constructor.call(this,
+ query,
+ data,
+ defaultOptions,
+ Chartist.extend({}, defaultOptions, options),
+ responsiveOptions);
+ }
+
+ // Creating pie chart type in Chartist namespace
+ Chartist.Pie = Chartist.Base.extend({
+ constructor: Pie,
+ createChart: createChart,
+ determineAnchorPosition: determineAnchorPosition
+ });
+
+}(window, document, Chartist));
+
+return Chartist;
+
+}));
diff --git a/app/assets/javascripts/application/vendor/jquery.multibox.js b/app/assets/javascripts/application/vendor/jquery.multibox.js
new file mode 100644
index 0000000..d3a3ce4
--- /dev/null
+++ b/app/assets/javascripts/application/vendor/jquery.multibox.js
@@ -0,0 +1,197 @@
+(function ($) {
+
+ 'use strict';
+
+ function Multibox($el, options) {
+ this.$el = $el;
+ this.options = options;
+ this.draw();
+ this.listen();
+ }
+
+ Multibox.prototype.destroy = function destroy() {
+ this.$inputs.off();
+ this.$el.detach();
+ this.$container.replaceWith(this.$el);
+
+ if (this.previousType) {
+ this.$el.attr('type', this.previousType);
+ }
+ };
+
+ Multibox.prototype.draw = function draw() {
+ var classNames = this.options.classNames;
+ var inputAutofocus = this.$el.attr('autofocus');
+ var inputType = this.$el.attr('type');
+ var inputValue = this.$el.val();
+
+ var focusIndex;
+ var inputIndex;
+ var text;
+
+ if (inputType !== 'hidden') {
+ this.previousType = inputType;
+ this.$el.attr('type', 'hidden');
+ }
+
+ this.$container = $('', {
+ 'class': classNames.container
+ });
+
+ var size = Array.apply(null, Array(this.options.inputCount));
+
+ this.$inputs = $();
+
+ $.each(size, function () {
+ this.$inputs = this.$inputs.add($('', {
+ 'class': classNames.input,
+ maxlength: 1,
+ size: 1,
+ type: 'text'
+ }));
+ }.bind(this));
+
+ this.$container .append(this.$inputs);
+ this.$el.replaceWith(this.$container);
+ this.$container.append(this.$el);
+
+ text = this.filterString(inputValue);
+
+ if (text.length) {
+ inputIndex = this.setFromString(0, text);
+ }
+
+ if (inputAutofocus) {
+ if (inputIndex === undefined) {
+ focusIndex = 0;
+ } else {
+ focusIndex = (inputIndex == this.$inputs.length ? inputIndex - 1 : inputIndex);
+ }
+ this.$inputs.eq(focusIndex).focus();
+ }
+ };
+
+ Multibox.prototype.handleKeydown = function handleKeydown(event) {
+ var $input = $(event.target);
+ var $prev;
+
+ if (event.keyCode === 8) {
+ event.preventDefault();
+
+ $prev = $input.prev();
+
+ if ($prev.length) {
+ $prev.focus();
+ }
+
+ if (event.target.value) {
+ $input.val('');
+ } else {
+ $prev.val('');
+ }
+ }
+
+ this.update();
+ };
+
+ Multibox.prototype.handleInput = function handleInput(event) {
+ var $input = $(event.target);
+ var $next = $input.next();
+ var value = $input.val();
+ var filtered = this.filterString(value);
+
+ $input.val(filtered);
+
+ if (filtered && $next.length) {
+ $next.focus();
+ }
+
+ this.update();
+ };
+
+ Multibox.prototype.handlePaste = function handlePaste(event) {
+ event.preventDefault();
+
+ var $input = $(event.target);
+ var clipboardData = event.originalEvent.clipboardData;
+ var text = clipboardData.getData('text');
+
+ var filtered = this.filterString(text);
+
+ if (!filtered.length) return;
+
+ var inputIndex = this.setFromString(this.$inputs.index($input), filtered);
+ var focusIndex = (inputIndex == this.$inputs.length ? inputIndex - 1 : inputIndex);
+
+ this.$inputs.eq(focusIndex).focus();
+
+ this.update();
+ };
+
+ Multibox.prototype.listen = function listen() {
+ this.$inputs.on('input', this.handleInput.bind(this));
+ this.$inputs.on('keydown', this.handleKeydown.bind(this));
+ this.$inputs.on('paste', this.handlePaste.bind(this));
+ };
+
+ Multibox.prototype.filterString = function filterString(str) {
+ return str.replace(this.options.regex, '');
+ };
+
+ Multibox.prototype.setFromString = function setFromString(index, str) {
+ var inputIndex = index;
+ var strIndex = 0;
+
+ while (this.$inputs.eq(inputIndex).length && str[strIndex]) {
+ this.$inputs.eq(inputIndex).val(str[strIndex]);
+ inputIndex++;
+ strIndex++;
+ }
+
+ return inputIndex;
+ };
+
+ Multibox.prototype.update = function update() {
+ var values = [];
+ var value;
+
+ this.$inputs.each(function(i, input) {
+ values.push(input.value);
+ });
+
+ value = values.join('');
+
+ this.$el
+ .val(value)
+ .trigger('change');
+ };
+
+ $.fn.multibox = function multibox(options) {
+ var instance;
+
+ if (typeof options === 'object' || options == undefined) {
+ options = (options || {});
+
+ options = $.extend({}, {
+ classNames: {
+ container: 'multibox',
+ input: 'multibox-input'
+ },
+ inputCount: 4,
+ regex: /\D/g
+ }, options);
+
+ if (this.length) {
+ instance = new Multibox(this, options);
+ this.data('multibox', instance);
+ }
+ } else if (options === 'destroy') {
+ if (this.data('multibox')) {
+ instance = this.data('multibox');
+ instance.destroy();
+ this.data('multibox', null);
+ }
+ }
+ };
+
+}(jQuery));
diff --git a/app/assets/stylesheets/application/application.scss b/app/assets/stylesheets/application/application.scss
new file mode 100644
index 0000000..f7ee744
--- /dev/null
+++ b/app/assets/stylesheets/application/application.scss
@@ -0,0 +1,42 @@
+@import 'global/reset';
+@import 'global/variables';
+@import 'global/mixins';
+@import 'global/fonts';
+
+@import 'vendor/*';
+@import 'elements/*';
+@import 'components/*';
+@import 'global/utility';
+
+html.main {
+ font-family: 'Source Sans Pro', sans-serif;
+ font-size:14px;
+ height: 100%;
+ max-height: 100%;
+ background:$backgroundGrey;
+ body {
+ display:flex;
+ flex-direction: column;
+ height: 100%;
+ max-height: 100%;
+ overflow-x:hidden;
+ }
+}
+
+html.subPage {
+ font-family: 'Source Sans Pro', sans-serif;
+ font-size:14px;
+ background:$backgroundGrey;
+ body {
+ padding-top:100px;
+ padding-bottom:100px;
+ }
+ .subPage__logo {
+ margin-bottom:40px;
+ text-align:center;
+ }
+}
+
+.turbolinks-progress-bar {
+ background-color: $darkBlue;
+}
diff --git a/app/assets/stylesheets/application/components/_admin_stats.scss b/app/assets/stylesheets/application/components/_admin_stats.scss
new file mode 100644
index 0000000..a4995c5
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_admin_stats.scss
@@ -0,0 +1,16 @@
+.adminStats {
+ display:flex;
+}
+
+.adminStats__stat {
+ flex:1 1 auto;
+ text-align: center;
+ dt {
+ font-weight:300;
+ color:#999;
+ }
+ dd {
+ font-size:26px;
+ font-weight:bold;
+ }
+}
diff --git a/app/assets/stylesheets/application/components/_button_set.scss b/app/assets/stylesheets/application/components/_button_set.scss
new file mode 100644
index 0000000..96b7020
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_button_set.scss
@@ -0,0 +1,12 @@
+.buttonSet {
+ .button {
+ margin-right:7px;
+ }
+}
+
+
+.buttonSet--center {
+ .button {
+ margin:0 5px;
+ }
+}
diff --git a/app/assets/stylesheets/application/components/_checkbox_list.scss b/app/assets/stylesheets/application/components/_checkbox_list.scss
new file mode 100644
index 0000000..d487d2b
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_checkbox_list.scss
@@ -0,0 +1,43 @@
+.checkboxList {
+ background:#fff;
+ border:1px solid #e4e8ef;
+ font:inherit;
+ width:100%;
+ color:$darkBlue;
+ font-weight:600;
+ appearance:none;
+ border-radius:4px;
+
+}
+
+.checkboxList__item {
+ padding:8px 10px;
+ display:flex;
+}
+
+.checkboxList__item + .checkboxList__item{
+ border-top:1px solid #e4e8ef;
+}
+
+.checkboxList__checkbox {
+ margin-right:15px;
+}
+
+.checkboxList__actualLabel {
+ color:$darkBlue;
+ font-weight:600;
+}
+
+
+.checkBoxList__text {
+ font-size:12px;
+ line-height:1.5;
+ color:$subBlue;
+ margin-top:3px;
+}
+
+.checkboxList__devEvent {
+ font-family:'Droid Sans Mono', fixed;
+ font-size:13px;
+ font-weight:bold;
+}
diff --git a/app/assets/stylesheets/application/components/_credential_list.scss b/app/assets/stylesheets/application/components/_credential_list.scss
new file mode 100644
index 0000000..402ee45
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_credential_list.scss
@@ -0,0 +1,105 @@
+.credentialList {
+ border-radius:4px;
+ overflow:hidden;
+ box-shadow:0 0 10px rgba(0,0,0,0.2);
+}
+
+.credentialList__item {
+ background:#fff;
+}
+.credentialList__item:nth-child(even) {
+ background:none;
+}
+
+.credentialList__item + .credentialList__item {
+ border-top:1px solid lighten(#ccd4e0, 10%);
+}
+
+.credentialList__link {
+ padding:15px;
+ display:flex;
+ &:hover {
+ background:#f2f5f8 !important;
+ }
+}
+
+.credentialList__properties {
+ flex: 1 1 auto;
+ min-width:1px;
+}
+
+.credentialList__name {
+ font-size:16px;
+ font-weight:600;
+ margin-bottom:10px;
+ overflow:hidden;
+ text-overflow:ellipsis;
+ white-space:nowrap;
+ line-height:1.2;
+ .label {
+ vertical-align:2px;
+ margin-left:4px;
+ }
+}
+
+.credentialList__key {
+ font-size:12px;
+ font-family:'Droid Sans Mono', fixed;
+ color:#999;
+}
+
+.credentialList__type {
+ margin-right:10px;
+ width:40px;
+}
+
+.credentialList__usedAt {
+ flex: 0 1 auto;
+ max-width:150px;
+ text-align:right;
+ margin-left:25px;
+ font-size:12px;
+ line-height:1.4;
+ color:#999;
+}
+
+.credentialList__usedAt--active {
+ color:$green;
+ .credentialList__usedAtTitle {
+ background-color:$green;
+ }
+
+}
+
+.credentialList__usedAt--quiet {
+ color:#bac647;
+ .credentialList__usedAtTitle {
+ background-color:#bac647;
+ }
+
+}
+
+.credentialList__usedAt--dormant {
+ color:#c7ad46;
+ .credentialList__usedAtTitle {
+ background-color:#c7ad46;
+ }
+
+}
+
+.credentialList__usedAt--inactive {
+ color:#d05026;
+ .credentialList__usedAtTitle {
+ background-color:#d05026;
+ }
+}
+
+.credentialList__usedAtTitle {
+ margin-bottom:3px;
+ background-color:#999;
+ color:#fff;
+ display:inline-block;
+ padding:1px 4px;
+ font-size:10px;
+ border-radius:3px;
+}
diff --git a/app/assets/stylesheets/application/components/_danger_zone.scss b/app/assets/stylesheets/application/components/_danger_zone.scss
new file mode 100644
index 0000000..316f17e
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_danger_zone.scss
@@ -0,0 +1,7 @@
+.dangerZone {
+ border:3px dashed $red;
+ border-radius:4px;
+ padding:25px;
+ color:$red;
+ background:lighten($red, 42%);
+}
diff --git a/app/assets/stylesheets/application/components/_data_table.scss b/app/assets/stylesheets/application/components/_data_table.scss
new file mode 100644
index 0000000..2baa171
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_data_table.scss
@@ -0,0 +1,78 @@
+.dataTable {
+ width:100%;
+ border:1px solid #ddd;
+ font-size:14px;
+ box-shadow:0 0 5px rgba(0,0,0,0.3);
+
+}
+
+.dataTable tr td {
+ border-left:1px solid #ddd;
+ padding:8px;
+ background:#fff;
+}
+
+.dataTable tr th {
+ text-align:left;
+ padding:8px;
+ background-color:#fffdf4;
+ font-weight:600;
+ vertical-align:top;
+}
+
+.dataTable thead td {
+ font-weight:600;
+ border-left:0 !important;
+ background:#fffdf4;
+ padding:8px 9px;
+ border-bottom:2px solid #222;
+}
+
+.dataTable tbody tr:nth-child(even) td {
+ background:#f5f5f5;
+}
+
+.dataTable tbody tr:hover td {
+ background:#ededed;
+}
+
+.dataTable__centerCell {
+ text-align:center;
+}
+
+.dataTable__rightCell {
+ text-align:right;
+}
+
+.dataTable__empty {
+ padding:40px 0 !important;
+ text-align:center;
+ color:#999;
+ font-style:italic;
+ font-size:12px;
+ &:hover {
+ background:#fff !important;
+ }
+}
+
+.dataTable__inputCell {
+ padding:0 !important;
+ input {
+ width:100%;
+ padding:8px;
+ border:0;
+ font:inherit;
+ background:transparent;
+ font-weight:bold;
+ color:#fb8424;
+ }
+}
+
+
+.dataTable__redRow {
+ td {
+ background-color:#fff0f1 !important;
+ color:#cd2f3b !important;
+ .u-link { color:#cd2f3b;}
+ }
+}
diff --git a/app/assets/stylesheets/application/components/_delivery_list.scss b/app/assets/stylesheets/application/components/_delivery_list.scss
new file mode 100644
index 0000000..6f641ae
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_delivery_list.scss
@@ -0,0 +1,86 @@
+.deliveryList {
+ color:$darkBlue;
+ border-radius:4px;
+ overflow:hidden;
+ box-shadow:0 0 10px rgba(0,0,0,0.2);
+}
+
+.deliveryList__item {
+ background:#fff;
+ padding:15px;
+}
+.deliveryList__item:nth-child(even) {
+ background:none;
+}
+
+.deliveryList__item + .deliveryList__item {
+ border-top:1px solid lighten(#ccd4e0, 10%);
+}
+
+.deliveryList__top {
+ display:flex;
+ justify-content:space-between;
+ align-items:flex-start;
+}
+
+.deliveryList__time {
+}
+
+.deliveryList__status {
+ display:flex;
+}
+
+.deliveryList__secure {
+ height:12px;
+ margin-right:7px;
+ margin-top:2px;
+}
+
+.deliveryList__errorCode {
+ font-size:12px;
+ color:$subBlue;
+ margin-top:5px;
+}
+
+.deliveryList__error {
+ margin-top:5px;
+ font-size:12px;
+ color:$subBlue;
+}
+
+.deliveryList__error--output {
+ background:$subBlue;
+ color:#fff;
+ font-size:10px;
+ font-family:'Droid Sans Mono', fixed;
+ padding:10px;
+ border-radius:4px;
+ margin-top:8px;
+ word-wrap:break-word;
+}
+
+.deliveryList__error--output-ref {
+ opacity:0.5;
+}
+
+.deliveryList__item--header {
+ p + p {
+ margin-top:8px;
+ }
+}
+
+
+.deliveryList__techLink {
+ display:inline-block;
+ font-size:10px;
+ color:$subBlue;
+ margin-top:8px;
+ text-decoration: underline;
+}
+
+.deliveryList-removeLink {
+ text-align:right;
+ font-size:12px;
+ color:#999;
+ margin-top:15px;
+}
diff --git a/app/assets/stylesheets/application/components/_domain_list.scss b/app/assets/stylesheets/application/components/_domain_list.scss
new file mode 100644
index 0000000..8551c81
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_domain_list.scss
@@ -0,0 +1,108 @@
+.domainList {
+ border-radius:4px;
+ overflow:hidden;
+ box-shadow:0 0 10px rgba(0,0,0,0.2);
+}
+
+.domainList__item {
+ display:block;
+ background:#fff;
+ padding:15px;
+ display:flex;
+ justify-content:space-between;
+}
+.domainList__item:nth-child(even) {
+ background:none;
+}
+
+.domainList__item + .domainList__item {
+ border-top:1px solid lighten(#ccd4e0, 10%);
+}
+
+.domainList__details {
+ flex: 1 1 auto;
+ min-width: 1px;
+}
+
+.domainList__properties {
+ text-align:right;
+ flex:0 0 auto;
+ margin-left:25px;
+}
+
+.domainList__name {
+ font-size:16px;
+ font-weight:600;
+ margin-bottom:6px;
+ overflow:hidden;
+ text-overflow:ellipsis;
+ span.label {
+ vertical-align:2px;
+ }
+}
+
+.domainList__verificationTime {
+ color:#999;
+}
+
+.domainList__links {
+ margin-top:12px;
+ display:flex;
+ justify-content:flex-end;
+ font-size:12px;
+ text-decoration: underline;
+ a {
+ margin-left:10px;
+ }
+}
+
+.domainList__delete {
+ color:$red;
+ margin-left:10px;
+}
+
+.domainList__verificationLink {
+ background:$blue;
+ color:#fff;
+ padding:1px 7px;
+ border-radius:4px;
+ font-size:12px;
+}
+
+.domainList__checks {
+ display:flex;
+}
+
+.domainList__check {
+ margin-right:15px;
+ font-size:12px;
+}
+
+.domainList__check--ok {
+ background:image-url('icons/tick-green.svg') no-repeat 0 3px / 12px;
+ padding-left:15px;
+ color:$green;
+}
+
+.domainList__check--neutral {
+ background:image-url('icons/tick-grey.svg') no-repeat 0 3px / 12px;
+ padding-left:15px;
+ color:#aaa;
+}
+
+.domainList__check--neutral-cross {
+ background:image-url('icons/cross-grey.svg') no-repeat 0 3px / 9px;
+ padding-left:12px;
+ color:#aaa;
+}
+
+
+.domainList__check--warning {
+ background:image-url('icons/cross-orange.svg') no-repeat 0 3px / 9px;
+ padding-left:12px;
+ color:$orange;
+}
+
+.domainList__check a:hover {
+ text-decoration:underline;
+}
diff --git a/app/assets/stylesheets/application/components/_endpoint_list.scss b/app/assets/stylesheets/application/components/_endpoint_list.scss
new file mode 100644
index 0000000..f8f2257
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_endpoint_list.scss
@@ -0,0 +1,51 @@
+.endpointList {
+ border-radius:4px;
+ overflow:hidden;
+ box-shadow:0 0 10px rgba(0,0,0,0.2);
+}
+
+.endpointList__item {
+ background:#fff;
+}
+.endpointList__item:nth-child(even) {
+ background:none;
+}
+
+.endpointList__item + .endpointList__item {
+ border-top:1px solid lighten(#ccd4e0, 10%);
+}
+
+.endpointList__link {
+ padding:15px;
+ display:block;
+ display:flex;
+ &:hover {
+ background:#f2f5f8 !important;
+ }
+}
+
+.endpointList__main {
+ width:60%;
+ flex: 1 1 auto;
+}
+
+
+.endpointList__details {
+ flex: 1 1 auto;
+ width:40%;
+}
+
+.endpointList__name {
+ font-size:16px;
+ font-weight:600;
+ margin-bottom:8px;
+}
+
+.endpointList__url {
+ font-size:12px;
+ color:#999;
+}
+
+.endpointList__details {
+ line-height:1.5;
+}
diff --git a/app/assets/stylesheets/application/components/_error_explanation.scss b/app/assets/stylesheets/application/components/_error_explanation.scss
new file mode 100644
index 0000000..e1c86ba
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_error_explanation.scss
@@ -0,0 +1,32 @@
+.errorExplanation {
+ border:1px solid $orange;
+ margin-bottom:25px;
+ color:$orange;
+ padding:15px;
+ box-shadow:0 0 10px lighten($red, 30%);
+ background:image-url('animals/fox.svg') #fff no-repeat 15px 15px / 50px;
+ padding-left:75px;
+ border-radius:4px;
+ line-height:1.5;
+}
+
+.errorExplanation h2 {
+ display:none;
+}
+
+.errorExplanation p {
+ display:none;
+}
+
+.errorExplanation ul li {
+ list-style:disc;
+ margin-left:20px;
+}
+
+html.subPage {
+ .errorExplanation {
+ background:none;
+ padding-left:15px;
+ line-height:1.3;
+ }
+}
diff --git a/app/assets/stylesheets/application/components/_field_set.scss b/app/assets/stylesheets/application/components/_field_set.scss
new file mode 100644
index 0000000..03cf482
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_field_set.scss
@@ -0,0 +1,128 @@
+.fieldSet {
+
+}
+
+.fieldSet__field {
+ display:flex;
+}
+
+.fieldSet__field + .fieldSet__field {
+ margin-top:20px;
+}
+
+.fieldSet__label {
+ display:block;
+ font-weight:600;
+ text-transform: uppercase;
+ font-size:12px;
+ color:$darkBlue;
+ margin-top:11px;
+ width:20%;
+}
+
+.fieldSet--wide .fieldSet__label {
+ width:40%;
+}
+
+.fieldSet__input {
+ flex: 0 0 auto;
+ width:80%;
+}
+
+.fieldSet--wide .fieldSet__input {
+ width:60%;
+}
+
+.fieldSet__text {
+ font-size:12px;
+ line-height:1.5;
+ color:$subBlue;
+ margin-top:5px;
+}
+
+.fieldSetSubmit {
+ margin-left:20%;
+ margin-top:40px;
+ display:flex;
+}
+
+.fieldSetSubmit--wide {
+ margin-left:40%;
+}
+
+.fieldSetSubmit__delete {
+ flex: 1 0 auto;
+ text-align:right;
+ .button {
+ margin-right:0;
+ }
+}
+
+.fieldSet__title {
+ margin-top:40px;
+ font-weight:600;
+ font-size:16px;
+ margin-left:20%;
+ color:$blue;
+ border-bottom:2px solid #e4e8ef;
+ padding-bottom:5px;
+ margin-bottom:20px;
+}
+
+.fieldSet__title--noMargin {
+ margin-top:0;
+}
+
+.fieldSet__title--withSubText {
+ margin-bottom:5px;
+}
+
+.fieldSet__titleSubText {
+ margin-left:20%;
+ font-size:12px;
+ color:$subBlue;
+ line-height:1.5;
+ margin-bottom:20px;
+}
+
+.fieldSet--compact {
+ .fieldSet__field {
+ display:block;
+ }
+ .fieldSet__field + .fieldSet__field {
+ margin-top:0;
+ }
+
+ .fieldSet__label {
+ width:100%;
+ margin-bottom:5px;
+ }
+ .fieldSet__input {
+ width:100%;
+ }
+ .fieldSet__fieldPair {
+ display:flex;
+ justify-content: space-between;
+ .fieldSet__field {
+ width:48%;
+ }
+ }
+}
+
+.fieldSet__inputPair {
+ display:flex;
+ justify-content: space-between;
+ .input + .input {
+ margin-left:10px;
+ }
+}
+
+.fieldSet__checkboxListAfter {
+ margin-bottom:6px;
+}
+
+.fieldSet__selectList {
+ select + select {
+ margin-top:6px;
+ }
+}
diff --git a/app/assets/stylesheets/application/components/_flash_display.scss b/app/assets/stylesheets/application/components/_flash_display.scss
new file mode 100644
index 0000000..e69de29
diff --git a/app/assets/stylesheets/application/components/_flash_message.scss b/app/assets/stylesheets/application/components/_flash_message.scss
new file mode 100644
index 0000000..b99d783
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_flash_message.scss
@@ -0,0 +1,30 @@
+html.main .flashMessage {
+ position:fixed;
+ background:$red;
+ z-index:5000;
+ left:25px;
+ top:25px;
+ width:300px;
+ color:#fff;
+ padding:15px;
+ border-radius:4px;
+ font-size:16px;
+ box-shadow:0 0 15px rgba(0,0,0,0.8);
+ cursor:pointer;
+}
+
+html.main .flashMessage--notice {
+ background:$green;
+}
+
+html.subPage .flashMessage {
+ background:$red;
+ color:#fff;
+ font-size:14px;
+ padding:15px;
+ line-height:1.4;
+}
+
+html.subPage .flashMessage--notice {
+ background:$green;
+}
diff --git a/app/assets/stylesheets/application/components/_grid.scss b/app/assets/stylesheets/application/components/_grid.scss
new file mode 100644
index 0000000..7a208be
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_grid.scss
@@ -0,0 +1,65 @@
+.row {
+ clear:both;
+ margin-left:-20px;
+ margin-right:-20px;
+ @include clearfix;
+}
+
+.row--noPadding {
+ margin-left:0;
+ margin-right:0;
+ .col {
+ padding-left:0;
+ padding-right:0;
+ }
+}
+
+.col {
+ float:left;
+ padding-left:20px;
+ padding-right:20px;
+}
+
+.col--1 { width:5%; }
+.col--2 { width:10%; }
+.col--3 { width:15%; }
+.col--4 { width:20%; }
+.col--5 { width:25%; }
+.col--6 { width:30%; }
+.col--7 { width:35%; }
+.col--8 { width:40%; }
+.col--9 { width:45%; }
+.col--10 { width:50%; }
+.col--11 { width:55%; }
+.col--12 { width:60%; }
+.col--13 { width:65%; }
+.col--14 { width:70%; }
+.col--15 { width:75%; }
+.col--16 { width:80%; }
+.col--17 { width:85%; }
+.col--18 { width:90%; }
+.col--19 { width:95%; }
+
+.row--2col {
+ margin-left:0;
+ margin-right:0;
+ .col:first-child {
+ padding-left:0;
+ padding-right:10px;
+ }
+ .col:last-child {
+ padding-left:10px;
+ padding-right:0;
+ }
+}
+
+@media(max-width: 1000px) {
+ .col--collapse {
+ width: 100%;
+ margin-bottom: 50px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/application/components/_headers_list.scss b/app/assets/stylesheets/application/components/_headers_list.scss
new file mode 100644
index 0000000..7987452
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_headers_list.scss
@@ -0,0 +1,26 @@
+.headersList {
+
+}
+
+.headersList__item {
+ display:flex;
+ font-family:'Droid Sans Mono', fixed;
+ font-size:12px;
+ justify-content:space-between;
+ dt {
+ color:$blue;
+ width:30%;
+ text-align:right;
+ font-weight:bold;
+ }
+
+ dd {
+ width:68%;
+ word-wrap:break-word;
+
+ }
+}
+
+.headersList__item + .headersList__item {
+ margin-top:15px;
+}
diff --git a/app/assets/stylesheets/application/components/_inlineError.scss b/app/assets/stylesheets/application/components/_inlineError.scss
new file mode 100644
index 0000000..6a52dd8
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_inlineError.scss
@@ -0,0 +1,6 @@
+.inlineError {
+ background:$red;
+ color:#fff;
+ padding:15px;
+ border-radius:4px;
+}
diff --git a/app/assets/stylesheets/application/components/_invoice_list.scss b/app/assets/stylesheets/application/components/_invoice_list.scss
new file mode 100644
index 0000000..59e84c8
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_invoice_list.scss
@@ -0,0 +1,45 @@
+.invoiceList {
+ border-radius:4px;
+ overflow:hidden;
+ box-shadow:0 0 10px rgba(0,0,0,0.2);
+}
+
+.invoiceList__item {
+ background:#fff;
+}
+.invoiceList__item:nth-child(even) {
+ background:none;
+}
+
+.invoiceList__item + .invoiceList__item {
+ border-top:1px solid lighten(#ccd4e0, 10%);
+}
+
+.invoiceList__link {
+ padding:15px;
+ display:flex;
+ &:hover {
+ background:#f2f5f8 !important;
+ }
+}
+
+.invoiceList__number {
+ width:70px;
+ flex: 0 0 auto;
+ font-weight:bold;
+}
+
+.invoiceList__date {
+ flex: 1 1 auto;
+}
+
+.invoiceList__total {
+ width:100px;
+ flex: 0 0 auto;
+}
+
+.invoiceList__status {
+ width:50px;
+ text-align: right;
+ flex:0 0 auto;
+}
diff --git a/app/assets/stylesheets/application/components/_ip_list.scss b/app/assets/stylesheets/application/components/_ip_list.scss
new file mode 100644
index 0000000..2fd2b57
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_ip_list.scss
@@ -0,0 +1,48 @@
+.ipList {
+ border-radius:4px;
+ overflow:hidden;
+ box-shadow:0 0 10px rgba(0,0,0,0.2);
+}
+
+.ipList__item {
+ display:block;
+ background:#fff;
+ padding:15px;
+ justify-content:space-between;
+}
+.ipList__item:nth-child(even) {
+ background:none;
+}
+
+.ipList__item + .ipList__item {
+ border-top:1px solid lighten(#ccd4e0, 10%);
+}
+
+.ipList__name {
+ font-size:16px;
+ font-weight:600;
+ margin-bottom:10px;
+}
+
+.ipList__address {
+ display:flex;
+}
+
+.ipList__address + .ipList__address {
+ margin-top:5px;
+}
+
+.ipList__ipv4 {
+ width:120px;
+}
+
+.ipList__ipv6 {
+ width:200px;
+}
+
+.ipList__address--header {
+ font-size:12px;
+ color:#999;
+ border-bottom:1px solid #ccc;
+ padding-bottom:4px;
+}
diff --git a/app/assets/stylesheets/application/components/_ip_pool_rule_list.scss b/app/assets/stylesheets/application/components/_ip_pool_rule_list.scss
new file mode 100644
index 0000000..4e22559
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_ip_pool_rule_list.scss
@@ -0,0 +1,48 @@
+.ipPoolRuleList {
+ border-radius:4px;
+ overflow:hidden;
+ box-shadow:0 0 10px rgba(0,0,0,0.2);
+}
+
+.ipPoolRuleList__item {
+ background:#fff;
+}
+.ipPoolRuleList__item:nth-child(even) {
+ background:none;
+}
+
+.ipPoolRuleList__item + .ipPoolRuleList__item {
+ border-top:1px solid lighten(#ccd4e0, 10%);
+}
+
+.ipPoolRuleList__link {
+ padding:15px;
+ display:block;
+ &:hover {
+ background:#f2f5f8 !important;
+ }
+}
+
+.ipPoolRuleList__condition {
+ display:flex;
+ dt {
+ width:180px;
+ color:#999;
+ padding-top:1px;
+ }
+ dd {
+ ul li {
+ line-height:1.4;
+ }
+ }
+}
+
+.ipPoolRuleList__condition + .ipPoolRuleList__condition {
+ margin-top:15px;
+}
+
+.ipPoolRuleListDefault {
+ text-align:center;
+ margin-top:25px;
+ color:#999;
+}
diff --git a/app/assets/stylesheets/application/components/_large_list.scss b/app/assets/stylesheets/application/components/_large_list.scss
new file mode 100644
index 0000000..934fa48
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_large_list.scss
@@ -0,0 +1,50 @@
+.largeList {
+ font-size:16px;
+ color:$darkBlue;
+ border-radius:4px;
+ overflow:hidden;
+ box-shadow:0 0 10px rgba(0,0,0,0.2);
+}
+
+.largeList__item {
+ display:block;
+ background:#fff;
+}
+.largeList__item:nth-child(even) {
+ background:none;
+}
+
+.largeList__item + .largeList__item {
+ border-top:1px solid lighten(#ccd4e0, 10%);
+}
+
+
+.largeList__item.is-highlighted {
+ background:$blue;
+ color:#fff;
+}
+
+.largeList__link {
+ display:block;
+ padding:15px;
+}
+
+.largeList__link:hover {
+ background:$blue;
+ color:#fff;
+}
+
+.largeList__link:active {
+ background:darken($blue, 5%);
+}
+
+.largeList__subText {
+ color:$subBlue;
+ font-size:13px;
+ margin-top:5px;
+}
+
+.largeList__rightLabel {
+ float:right;
+ line-height:0.8;
+}
diff --git a/app/assets/stylesheets/application/components/_limit.scss b/app/assets/stylesheets/application/components/_limit.scss
new file mode 100644
index 0000000..a2898de
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_limit.scss
@@ -0,0 +1,37 @@
+.limits {
+ font-size:16px;
+ color:$darkBlue;
+ border-radius:4px;
+ background:#fff;
+ overflow:hidden;
+ box-shadow:0 0 10px rgba(0,0,0,0.2);
+ display:flex;
+}
+
+.limits__limit {
+ flex: 1 1 auto;
+ width:50%;
+ padding:15px;
+ text-align:center;
+}
+
+.limits__limit + .limits__limit {
+ border-left:1px solid #efefef;
+}
+
+.limits__title {
+ font-size:14px;
+ margin-bottom:5px;
+ font-weight:600;
+}
+
+.limits__value {
+ font-size:32px;
+ font-weight:900;
+ color:$blue;
+}
+
+.limits__frequency {
+ font-size:14px;
+ color:#999
+}
diff --git a/app/assets/stylesheets/application/components/_login_form.scss b/app/assets/stylesheets/application/components/_login_form.scss
new file mode 100644
index 0000000..030b056
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_login_form.scss
@@ -0,0 +1,22 @@
+.loginForm {
+
+}
+
+
+.loginForm__input {
+ margin-bottom:15px;
+}
+
+.loginForm__submit {
+ display:flex;
+ justify-content:space-between;
+ align-items:center;
+}
+
+
+.loginForm__links {
+ font-size:12px;
+ color:#999;
+ text-decoration: underline;
+ line-height:1.7;
+}
diff --git a/app/assets/stylesheets/application/components/_mail_graph.scss b/app/assets/stylesheets/application/components/_mail_graph.scss
new file mode 100644
index 0000000..131566d
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_mail_graph.scss
@@ -0,0 +1,66 @@
+.mailGraph {
+}
+
+.mailGraph__startTime {
+
+}
+
+.mailGraph__graph {
+ min-height:230px;
+ margin-bottom:4px;
+ .ct-series-a .ct-line { stroke:$blue;}
+ .ct-series-a .ct-area { fill:$blue; fill-opacity:0.2;}
+ .ct-series-b .ct-line { stroke:$turquoise;}
+ .ct-series-b .ct-area { fill:$turquoise;fill-opacity:0.2;}
+
+ .ct-point { stroke-width: 0; }
+ .ct-line { stroke-width:1px; }
+ .ct-area { fill-opacity: 0.4; }
+}
+
+
+.mailGraph__empty {
+ margin:100px 0;
+ text-align:center;
+ color:#aaa;
+}
+
+.mailGraph__key {
+ font-size:12px;
+ margin-bottom:15px;
+ float:right;
+ li {
+ float:left;
+ margin-left:10px;
+ color:$turquoise;
+ }
+ li:before {
+ display:block;
+ float:left;
+ width:10px;
+ content: " ";
+ margin-top:3px;
+ height:10px;
+ border:1px solid $turquoise;
+ background:lighten($turquoise, 20%);
+ margin-right:6px;
+ }
+
+ li.mailGraph__key--out {
+ color:$blue;
+ &:before {
+ border-color:$blue ;
+ background:lighten($blue, 30%);
+ }
+ }
+
+
+}
+
+.mailGraph__labels {
+ display:flex;
+ margin-left:40px;
+ justify-content:space-between;
+ font-size:12px;
+ color:#999;
+}
diff --git a/app/assets/stylesheets/application/components/_message_activity.scss b/app/assets/stylesheets/application/components/_message_activity.scss
new file mode 100644
index 0000000..255ee14
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_message_activity.scss
@@ -0,0 +1,52 @@
+.messageActivity {
+
+}
+
+.messageActivity__event {
+ display:flex;
+}
+
+.messageActivity__event + .messageActivity__event {
+ border-top:1px solid #ddd;
+ padding-top:15px;
+ margin-top:15px;
+}
+
+.messageActivity__timestamp {
+ width:170px;
+ font-size:12px;
+ color:#999;
+ flex: 0 0 auto;
+}
+
+
+.messageActivity__details {
+ background:image-url('icons/conveyor.svg') no-repeat 0 2px / 24px;
+ padding-left:35px;
+}
+
+.messageActivity--detailsDelivery {
+ background-image:image-url('icons/truck.svg');
+}
+
+
+.messageActivity--detailsClick {
+ background:image-url('icons/mouse.svg') no-repeat 5px 2px / 12px;
+}
+
+.messageActivity--detailsLoad {
+ background-image:image-url('icons/eye.svg');
+}
+
+.messageActivity__subject {
+ font-weight:600;
+ font-size:14px;
+ word-break:break-all;
+}
+
+.messageActivity__extra {
+ margin-top:4px;
+ color:#999;
+ font-size:12px;
+ line-height:1.4;
+}
diff --git a/app/assets/stylesheets/application/components/_message_header.scss b/app/assets/stylesheets/application/components/_message_header.scss
new file mode 100644
index 0000000..883c73e
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_message_header.scss
@@ -0,0 +1,49 @@
+.messageHeader {
+ margin:20px 35px;
+}
+
+.messageHeader__subject {
+ font-size:18px;
+ font-weight:700;
+ margin-bottom:6px;
+}
+
+.messageHeader__status {
+ margin-bottom:4px;
+}
+
+.messageHeader__timestamp {
+ color:$subBlue;
+}
+
+.messageHeader__basicProperties {
+ display:flex;
+ dl {
+ margin-right:25px;
+ display:flex;
+ dt {
+ color:$subBlue;
+ margin-bottom:5px;
+ margin-right:15px;
+ }
+ dd {
+ font-weight:600;
+ }
+ }
+}
+
+
+.messageHeader__header {
+ background-size:35px;
+ background-repeat:no-repeat;
+ background-position:right 0;
+}
+
+.messageHeader__header--incoming {
+ background-image:image-url('icons/incoming-mail.svg');
+}
+
+.messageHeader__header--outgoing {
+ background-image:image-url('icons/outgoing-mail.svg');
+}
+
diff --git a/app/assets/stylesheets/application/components/_message_list.scss b/app/assets/stylesheets/application/components/_message_list.scss
new file mode 100644
index 0000000..c5ac59f
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_message_list.scss
@@ -0,0 +1,78 @@
+.messageList {
+ box-shadow:0 0 10px rgba(0,0,0,0.15);
+ border-radius:4px;
+ overflow:hidden;
+}
+
+.messageList__message + .messageList__message {
+ border-top:1px solid lighten(#ccd4e0, 10%);
+}
+
+
+.messageList__link {
+ display:flex;
+ padding:15px;
+ background:#fff;
+ &:hover {
+ background:#f2f5f8 !important;
+ }
+}
+
+.messageList__message:nth-child(even) {
+ .messageList__link {
+ background:transparent;
+ }
+}
+
+.messageList__details {
+ flex: 1 1 auto;
+ overflow: hidden;
+ min-width: 1px;
+ background-repeat:no-repeat;
+ background-size:16px;
+ background-position:0 2px;
+ padding-left:25px;
+}
+
+.messageList__details--incoming {
+ background-image:image-url('icons/incoming-mail.svg');
+}
+
+.messageList__details--outgoing {
+ background-image:image-url('icons/outgoing-mail.svg');
+}
+
+
+.messageList__subject {
+ font-weight:600;
+ margin-bottom:7px;
+ line-height:1.4;
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+
+.messageList__addresses {
+ display:flex;
+ line-height:1.4;
+ font-size:12px;
+ dt {
+ font-weight:600;
+ }
+ dd {
+ margin-left:15px;
+ margin-right:25px;
+ }
+}
+
+
+.messageList__meta {
+ flex: 0 0 auto;
+ margin-left:15px;
+ justify-self: flex-end;
+ text-align:right;
+}
+
+.messageList__timestamp {
+ color:#999;
+ font-size:12px;
+ margin-bottom:5px;
+}
diff --git a/app/assets/stylesheets/application/components/_message_properties_page.scss b/app/assets/stylesheets/application/components/_message_properties_page.scss
new file mode 100644
index 0000000..a64fcba
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_message_properties_page.scss
@@ -0,0 +1,49 @@
+.messagePropertiesPage {
+ display:flex;
+ justify-content:space-between;
+}
+
+.messagePropertiesPage__left {
+ width:45%;
+}
+
+.messagePropertiesPage__right {
+ border-left:3px solid #eee;
+ padding-left:35px;
+ width:52%;
+}
+
+.messagePropertiesPage__property {
+ margin-bottom:25px;
+ min-width:1px;
+ dt {
+ color:$subBlue;
+ margin-bottom:3px;
+ }
+ dd {
+ font-size:16px;
+ font-weight:600;
+ text-overflow:ellipsis;
+ overflow:hidden;
+ white-space:nowrap;
+ }
+}
+
+.messagePropertiesPage__property--locked {
+ background:image-url('icons/lock.svg') no-repeat 0 1px / 14px;
+ padding-left:20px;
+}
+
+.messagePropertiesPage__propertyPair {
+ display:flex;
+ justify-content:space-between;
+ dl {
+ width:47%;
+ }
+}
+
+.messagePropertiesPage__title {
+ font-size:20px;
+ font-weight:700;
+ margin-bottom:25px;
+}
diff --git a/app/assets/stylesheets/application/components/_message_search.scss b/app/assets/stylesheets/application/components/_message_search.scss
new file mode 100644
index 0000000..61f3a15
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_message_search.scss
@@ -0,0 +1,96 @@
+.messageSearch {
+ margin-bottom:25px;
+ position:relative;
+}
+
+.messageSearch__help {
+ position:absolute;
+ z-index:100;
+ right:20px;
+ top:11px;
+ font-size:12px;
+ color:$subBlue;
+ text-decoration:underline;
+}
+
+.messageSearch__input {
+ width:100%;
+ margin:0;
+
+ border:2px solid #e0e7f3;
+ border-radius:25px;
+ padding:6px 13px;
+ font:inherit;
+ font-size:14px;
+ font-weight:600;
+ position:relative;
+ color:$darkBlue;
+ background:image-url('icons/search.svg') #fff no-repeat 12px 7px / 19px;
+ padding-left:38px;
+ padding-right:150px;
+ &::placeholder {
+ color:#98a5c0;
+ font-weight:300;
+ }
+ &:focus {
+ border-color:$blue;
+ }
+ &.is-spinning {
+ background-image:image-url('spinner-sub.gif');
+ background-position: 12px 5px;
+ }
+}
+
+.messageSearch__helpBox {
+ color:$darkBlue;
+ margin-top:25px;
+ border-radius:4px;
+ background:#fffdf1;
+ padding:25px;
+ overflow:hidden;
+ box-shadow:0 0 10px rgba(0,0,0,0.2);
+ display:flex;
+ justify-content:space-between;
+}
+
+.messageSearch__left {
+ width:40%;
+}
+
+.messageSearch__helpBoxTitle {
+ font-size:18px;
+ font-weight:600;
+ margin-bottom:10px;
+}
+
+.messageSearch__helpBoxText {
+ line-height:1.5;
+ font-size:14px;
+ color:$subBlue;
+}
+
+.messageSearch__right {
+ width:55%;
+}
+
+.messageSearch__definition {
+ dt {
+ font-family:'Droid Sans Mono', fixed;
+ font-weight:bold;
+ font-size:15px;
+ color:$blue;
+ }
+ dd {
+ font-size:13px;
+ margin-top:4px;
+ code {
+ font-family:'Droid Sans Mono', fixed;
+ font-size:12px;
+ color:darken($blue, 15%);
+ }
+ }
+}
+
+.messageSearch__definition + .messageSearch__definition {
+ margin-top:18px;
+}
diff --git a/app/assets/stylesheets/application/components/_multibox.scss b/app/assets/stylesheets/application/components/_multibox.scss
new file mode 100644
index 0000000..47c5263
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_multibox.scss
@@ -0,0 +1,14 @@
+.multibox {
+ display: flex;
+ justify-content: space-between;
+ margin: 40px 0;
+}
+
+.multibox__input {
+ width:80px;
+ height:70px;
+ font: inherit;
+ font-weight: 700;
+ font-size:38px;
+ text-align:center;
+}
diff --git a/app/assets/stylesheets/application/components/_nav_bar.scss b/app/assets/stylesheets/application/components/_nav_bar.scss
new file mode 100644
index 0000000..b46b0da
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_nav_bar.scss
@@ -0,0 +1,63 @@
+.navBar {
+ background:$veryDarkBlue;
+ padding:10px 35px;
+ color:#fff;
+ ul {
+ display:flex;
+ }
+}
+
+.navBar--secondary {
+ background:lighten($veryDarkBlue, 44%);
+ .navBar__link.is-active {
+ color:$veryDarkBlue;
+ }
+}
+
+.navBar--tertiary {
+ background:#fff;
+ border:1px solid lighten($subBlue, 30%);
+ border-left:0;
+ border-right:0;
+ .navBar__link {
+ color:$subBlue;
+ }
+ .navBar__link.is-active {
+ color:$veryDarkBlue;
+ }
+
+}
+
+.navBar__item:not(:last-child) {
+ margin-right:35px;
+}
+
+.navBar__link.is-active {
+ color:#8abdff;
+ font-weight:600;
+}
+
+.navBar__item--end {
+ margin-left:auto;
+}
+
+.navBar__link:hover {
+ text-decoration:underline;
+}
+
+.navBar__itemCounter {
+ background:$red;
+ border-radius:4px;
+ padding:2px 2px 1px 2px;
+ line-height:1;
+ font-size:10px;
+ vertical-align:1px;
+ font-weight:300;
+ min-width:20px;
+ display:inline-block;
+ text-align:center;
+ margin-left:5px;
+ &.is-empty {
+ background-color:$darkBlue;
+ }
+}
diff --git a/app/assets/stylesheets/application/components/_new_message_type.scss b/app/assets/stylesheets/application/components/_new_message_type.scss
new file mode 100644
index 0000000..8c8fa2c
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_new_message_type.scss
@@ -0,0 +1,21 @@
+.newMessageType {
+ background-repeat:no-repeat;
+ background-size:20px;
+ background-position:15px 15px;
+ background-color:#4bc9c5;
+ color:#fff;
+ padding:15px;
+ padding-left:46px;
+ border-radius:4px;
+ border:1px solid darken(#4bc9c5, 10%);
+}
+
+.newMessageType--outgoing {
+ background-color:#0e69d5;
+ border-color:darken(#0e69d5, 10%);
+ background-image:image-url('icons/outgoing-mail-white.svg');
+}
+
+.newMessageType--incoming {
+ background-image:image-url('icons/incoming-mail-white.svg');
+}
diff --git a/app/assets/stylesheets/application/components/_no_data.scss b/app/assets/stylesheets/application/components/_no_data.scss
new file mode 100644
index 0000000..3670b0d
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_no_data.scss
@@ -0,0 +1,114 @@
+.noData {
+ text-align:center;
+ border-radius:4px;
+ padding:30px;
+ padding-top:100px;
+ box-shadow:0 0 10px rgba(0,0,0,0.15);
+ background:image-url('animals/panda.svg') #fff no-repeat center 25px / 50px;
+}
+
+.noData--clean {
+ box-shadow:none;
+ background-color:transparent;
+}
+
+.noData__title {
+ font-size:22px;
+ margin-bottom:10px;
+ font-weight:700;
+}
+
+.noData__text {
+ color:#888;
+ font-size:16px;
+ line-height:1.5;
+}
+
+.noData__button {
+ margin-top:20px;
+}
+
+.noData__postButtonText {
+ margin:auto;
+ margin-top:15px;
+ line-height:1.5;
+ width:70%;
+ color:$subBlue;
+}
+
+.noData--fox {
+ background-image:image-url('animals/fox.svg');
+ background-size:65px;
+}
+
+.noData--cat {
+ background-image:image-url('animals/cat.svg');
+ background-position:center 15px;
+ background-size:50px;
+}
+
+.noData--cat2 {
+ background-image:image-url('animals/cat2.svg');
+ background-size:55px;
+}
+
+.noData--cat3 {
+ background-image:image-url('animals/cat3.svg');
+ background-size:48px;
+}
+
+.noData--cat4 {
+ background-image:image-url('animals/cat4.svg');
+ background-size:50px;
+}
+
+.noData--cock {
+ background-image:image-url('animals/cock.svg');
+ background-size:50px;
+ background-position:center 15px;
+}
+
+.noData--deer {
+ background-image:image-url('animals/deer.svg');
+ background-position:center 15px;
+ background-size:65px;
+}
+
+.noData--dog {
+ background-image:image-url('animals/dog.svg');
+}
+
+.noData--monkey {
+ background-image:image-url('animals/monkey.svg');
+}
+
+.noData--owl {
+ background-image:image-url('animals/owl.svg');
+}
+
+.noData--penguin {
+ background-image:image-url('animals/penguin.svg');
+}
+
+.noData--panda {
+ background-image:image-url('animals/panda.svg');
+}
+
+.noData--goat {
+ background-image:image-url('animals/goat.svg');
+}
+
+.noData--koala {
+ background-image:image-url('animals/koala.svg');
+ background-size:65px;
+}
+
+.noData--wolf {
+ background-image:image-url('animals/wolf.svg');
+ background-size:45px;
+}
+
+.noData--lion {
+ background-image:image-url('animals/lion.svg');
+ background-size:45px;
+}
diff --git a/app/assets/stylesheets/application/components/_page_content.scss b/app/assets/stylesheets/application/components/_page_content.scss
new file mode 100644
index 0000000..4ba37c6
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_page_content.scss
@@ -0,0 +1,93 @@
+.pageContent {
+ margin:35px;
+}
+
+.pageContent__intro {
+ font-size:20px;
+ line-height:30px;
+ font-weight:300;
+ color:$subBlue;
+}
+
+.pageContent__title {
+ font-size:20px;
+ margin-bottom:10px;
+ font-weight:700;
+ color:$darkBlue;
+}
+
+.pageContent--compact {
+ max-width:600px;
+ margin:60px auto;
+}
+
+.pageContent__subTitle {
+ font-size:18px;
+ font-weight:600;
+ border-bottom:1px solid #ddd;
+ padding-bottom:3px;
+ margin-bottom:10px;
+}
+
+.pageContent__text {
+ line-height:1.5;
+ margin-bottom:15px;
+ .label {
+ vertical-align:1px;
+ margin-right:2px;
+ }
+}
+
+.pageContent__pageEntriesInfo {
+ font-size:12px;
+ color:$subBlue;
+ margin-bottom:10px;
+}
+
+.pageContent__definitions {
+ overflow:hidden;
+ dt {
+ width:30%;
+ float:left;
+ color:$subBlue;
+ }
+ dd {
+ margin-left:35%;
+ margin-bottom:15px;
+ word-wrap:break-word;
+ }
+}
+
+.pageContent__definitionCode {
+ font-size:16px;
+ font-weight:bold;
+ font-family:'Droid Sans Mono', fixed;
+}
+
+.pageContent__definitionCode + .pageContent__definitionText {
+ margin-top:6px;
+}
+
+.pageContent__definitionText {
+ color:$subBlue;
+}
+
+.pageContent__list {
+ line-height:1.5;
+ li {
+ list-style:square;
+ margin-left:25px;
+ }
+ li + li {
+ margin-top:15px;
+ }
+}
+
+.pageContent__helpLink {
+ a {
+ background:image-url('icons/help.svg') no-repeat 0 2px / 15px;
+ padding-left:20px;
+ text-decoration:underline;
+ color:$blue;
+ }
+}
diff --git a/app/assets/stylesheets/application/components/_page_header.scss b/app/assets/stylesheets/application/components/_page_header.scss
new file mode 100644
index 0000000..6ae0787
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_page_header.scss
@@ -0,0 +1,14 @@
+.pageHeader {
+ background:$darkBlue;
+ padding:22px 25px;
+}
+
+.pageHeader__title {
+ font-size:26px;
+ font-weight:300;
+ color:#fff;
+}
+
+.pageHeader__titlePrevious {
+ opacity:0.2;
+}
diff --git a/app/assets/stylesheets/application/components/_pagination.scss b/app/assets/stylesheets/application/components/_pagination.scss
new file mode 100644
index 0000000..2ae9a9d
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_pagination.scss
@@ -0,0 +1,26 @@
+nav.pagination {
+ font-size:12px;
+ text-align:center;
+ margin:25px 0;
+ span.page.current, a {
+ color:$blue;
+ display:inline-block;
+ line-height:1.3;
+ border:1px solid lighten($subBlue, 25%);
+ padding:3px 10px;
+ text-decoration:none;
+ border-radius:4px;
+ background:#fff;
+ margin:0 2px;
+ }
+
+ a:hover {
+ background-color:lighten(#ccc, 20%);
+ }
+
+ span.page.current {
+ background:$blue;
+ color:#fff;
+ border-color:darken($blue, 15%);
+ }
+}
diff --git a/app/assets/stylesheets/application/components/_rentention_limits.scss b/app/assets/stylesheets/application/components/_rentention_limits.scss
new file mode 100644
index 0000000..661fa79
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_rentention_limits.scss
@@ -0,0 +1,37 @@
+.retentionLimits {
+}
+
+.retentionLimits__limit {
+ display:flex;
+ align-items: center;
+}
+
+.retentionLimits__limit + .retentionLimits__limit {
+ margin-top:25px;
+}
+
+.retentionLimits__label {
+ width:200px;
+ flex: 0 0 auto;
+ font-weight:bold;
+ margin-right:25px;
+}
+
+.retentionLimits__info {
+ border-left:4px solid $blue;
+ padding-left:25px;
+}
+
+.retentionLimits__value {
+ color:$blue;
+ font-size:22px;
+ font-weight:700;
+ margin-bottom:6px;
+}
+
+.retentionLimits__text {
+ color:$subBlue;
+ font-size:12px;
+ line-height:1.5;
+}
+
diff --git a/app/assets/stylesheets/application/components/_route_list.scss b/app/assets/stylesheets/application/components/_route_list.scss
new file mode 100644
index 0000000..4a4a3af
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_route_list.scss
@@ -0,0 +1,67 @@
+.routeList {
+ border-radius:4px;
+ overflow:hidden;
+ box-shadow:0 0 10px rgba(0,0,0,0.2);
+}
+
+.routeList__item {
+ background:#fff;
+}
+.routeList__item:nth-child(even) {
+ background:none;
+}
+
+.routeList__item + .routeList__item {
+ border-top:1px solid lighten(#ccd4e0, 10%);
+}
+
+.routeList__link {
+ padding:15px;
+ display:block;
+ &:hover {
+ background:#f2f5f8 !important;
+ }
+}
+
+.routeList__name {
+ font-size:16px;
+ font-weight:600;
+ margin-bottom:13px;
+}
+
+.routeList__details {
+ display:flex;
+ justify-content:space-between;
+ min-width:1px;
+}
+
+.routeList__endpoint {
+ flex: 1 1 auto;
+ overflow:hidden;
+ color:#999;
+ text-overflow:ellipsis;
+ white-space:nowrap;
+ line-height:1.1;
+ background:image-url('icons/web.svg') no-repeat 0 0 / 12px;
+ padding-left:18px;
+ font-size:13px;
+}
+
+.routeList__endpoint--smtp_endpoint {
+ background-image:image-url('icons/email.svg');
+ background-size:12px;
+ background-position:0 1.5px;
+}
+
+.routeList__endpoint--address_endpoint {
+ background-image:image-url('icons/email.svg');
+ background-size:12px;
+ background-position:0 1.5px;
+}
+
+.routeList__spamMode {
+ font-size:12px;
+ color:#999;
+ margin-left:15px;
+ flex: 0 0 auto;
+}
diff --git a/app/assets/stylesheets/application/components/_route_name_input.scss b/app/assets/stylesheets/application/components/_route_name_input.scss
new file mode 100644
index 0000000..18f4156
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_route_name_input.scss
@@ -0,0 +1,17 @@
+.routeNameInput {
+ display:flex;
+ align-items:center;
+}
+
+.routeNameInput__at {
+ margin:0 7px;
+ font-size:18px;
+ color:$subBlue;
+}
+
+.routeNameInput__name {
+ width:40%;
+}
+
+.routeNameInput__domain {
+}
diff --git a/app/assets/stylesheets/application/components/_server_header.scss b/app/assets/stylesheets/application/components/_server_header.scss
new file mode 100644
index 0000000..b5ec48d
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_server_header.scss
@@ -0,0 +1,141 @@
+.serverHeader {
+ background:$darkBlue;
+ padding:25px;
+ display:flex;
+ color:#fff;
+ position:relative;
+}
+
+.serverHeader__stripe {
+ right:-35px;
+ margin-top:-5px;
+ background:#636363;
+ font-size:10px;
+ font-weight:600;
+ text-transform: uppercase;
+ text-align:center;
+ width:130px;
+ padding:4px 0;
+ transform:rotate(45deg);
+ position:absolute;
+}
+
+.serverHeader__stripe--live {
+ background-color:$green;
+}
+
+.serverHeader__stripe--suspended {
+ background-color:$red;
+}
+
+.serverHeader__info {
+ flex: 1 0 auto;
+ padding:8px;
+}
+
+.serverHeader__stats {
+ background-color:$veryDarkBlue;
+ width:180px;
+ flex: 0 0 auto;
+ padding:15px;
+ border-radius:4px;
+ a:hover {
+ text-decoration:underline;
+ }
+}
+
+.serverHeader__usage {
+ background:green;
+ width:320px;
+ padding:15px;
+ margin-left:10px;
+ background-color:$veryDarkBlue;
+ flex: 0 0 auto;
+ border-radius:4px;
+}
+
+.serverHeader__title {
+ font-size:18px;
+ font-weight:700;
+ margin-bottom:10px;
+}
+
+.serverHeader__list {
+ line-height:1.5;
+ font-size:12px;
+}
+
+.serverHeader__list--ok {
+ color:$green;
+}
+
+.serverHeader__list--warning {
+ color:$orange;
+}
+
+.serverHeader__statsList {
+ line-height:1.8;
+ font-size:12px;
+ li {
+ padding-left:22px;
+ font-weight:300;
+ }
+}
+
+.serverHeader__stat-held {
+ background:image-url('icons/pause-white.svg') no-repeat 0 4px / 13px;
+ padding-left:22px;
+}
+
+.serverHeader__stat-queue {
+ background:image-url('icons/box-white.svg') no-repeat 0 4px / 13px;
+}
+
+.serverHeader__stat-size {
+ background:image-url('icons/size-white.svg') no-repeat 0 4px / 13px;
+}
+
+.serverHeader__stat-bounces {
+ background:image-url('icons/bats-white.svg') no-repeat 0 4px / 13px;
+}
+
+.serverHeader__usageTitle {
+ color:#566576;
+ font-size:12px;
+ font-weight: 600;
+ margin-bottom:5px;
+}
+
+.serverHeader__usageLine {
+ display:flex;
+ font-size:12px;
+ align-items:center;
+}
+
+.serverHeader__usageLine + .serverHeader__usageLine {
+ margin-top:6px;
+}
+
+.serverHeader__usageLineLabel {
+ flex: 1 0 auto;
+}
+
+.serverHeader__usageLineBar {
+ width:100px;
+ line-height:0;
+}
+
+.serverHeader__usageLineValue {
+ width:60px;
+ text-align:right;
+ font-weight:600;
+}
+
+.serverHeader__usageLineValueLarge {
+ width:300px;
+ text-align:right;
+ color:$subBlue;
+ b {
+ color:#fff;
+ }
+}
diff --git a/app/assets/stylesheets/application/components/_sidebar.scss b/app/assets/stylesheets/application/components/_sidebar.scss
new file mode 100644
index 0000000..9cf159f
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_sidebar.scss
@@ -0,0 +1,57 @@
+.sidebar {
+ width:250px;
+ background:#fff;
+ flex: 0 0 auto;
+ z-index:200;
+ box-shadow:5px 0 8px -2px rgba(0,0,0,0.1);
+ overflow-y: auto;
+ @include scrollbars(6px);
+}
+
+.sidebar__search {
+ background:$lightBlue;
+ border-bottom:1px solid #d1dcea;
+ padding:15px;
+}
+
+.sidebar__searchInput {
+ width:100%;
+ margin:0;
+ border:1px solid #e0e7f3;
+ border-radius:25px;
+ padding:6px 13px;
+ font:inherit;
+ font-size:12px;
+ font-weight:600;
+ color:$darkBlue;
+ background:image-url('icons/search.svg') #fff no-repeat 10px 7px / 17px;
+ padding-left:33px;
+ &::placeholder {
+ color:#98a5c0;
+ font-weight:300;
+ }
+ &:focus {
+ border-color:$blue;
+ }
+}
+
+.sidebar__placeholder {
+ margin:60px 20px;
+ text-align:center;
+ color:$subBlue;
+ font-size:18px;
+ line-height:1.5;
+ font-weight:300;
+}
+
+.sidebar__new {
+ text-align:center;
+ margin-top:15px;
+ margin-bottom:15px;
+ color:#999;
+ font-size:12px;
+ a:hover {
+ color:$green;
+ }
+ text-decoration: underline;
+}
diff --git a/app/assets/stylesheets/application/components/_sidebar_server_list.scss b/app/assets/stylesheets/application/components/_sidebar_server_list.scss
new file mode 100644
index 0000000..82efbad
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_sidebar_server_list.scss
@@ -0,0 +1,39 @@
+.sidebarServerList {
+ font-size:12px;
+ color:$darkBlue;
+}
+
+.sidebarServerList__item {
+ border-bottom:1px solid #e6ebf0;
+}
+
+.sidebarServerList__link {
+ display: block;
+ padding:15px 20px;
+ &:hover {
+ background-color:#f2f5f8;
+ }
+}
+
+.sidebarServerList__link.is-active {
+ background-color:#f2f5f8;
+}
+
+.sidebarServerList__item.is-highlighted .sidebarServerList__link {
+ background-color:#fffedd;
+}
+
+.sidebarServerList__mode {
+ float:right;
+}
+
+.sidebarServerList__title {
+ font-size:13px;
+ font-weight:600;
+ margin-bottom:5px;
+}
+
+.sidebarServerList__quantity {
+ color:$subBlue;
+ font-size:11px;
+}
diff --git a/app/assets/stylesheets/application/components/_simple_pagination.scss b/app/assets/stylesheets/application/components/_simple_pagination.scss
new file mode 100644
index 0000000..49fa35b
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_simple_pagination.scss
@@ -0,0 +1,41 @@
+.simplePagination {
+ display:flex;
+ margin:25px 0;
+ justify-content:space-between;
+ font-size:12px;
+}
+
+.simplePagination__link {
+ color:$blue;
+ display:inline-block;
+ line-height:1.3;
+ border:1px solid lighten($subBlue, 25%);
+ padding:3px 10px;
+ text-decoration:none;
+ border-radius:4px;
+ background:#fff;
+ margin:0 2px;
+ &:hover {
+ border-color:$blue;
+ }
+}
+
+.simplePagination__next,
+.simplePagination__previous,
+.simplePagination__current {
+ width:33%;
+}
+
+.simplePagination__next {
+ text-align:right;
+}
+
+.simplePagination__current {
+ text-align:center;
+ color:$subBlue;
+ line-height:1.5;
+}
+
+.simplePagination__info {
+ font-weight:600;
+}
diff --git a/app/assets/stylesheets/application/components/_site_content.scss b/app/assets/stylesheets/application/components/_site_content.scss
new file mode 100644
index 0000000..bd1aaba
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_site_content.scss
@@ -0,0 +1,14 @@
+.siteContent {
+ align-items: stretch;
+ display: flex;
+ flex: 1 1 auto;
+ overflow: hidden;
+}
+
+.siteContent__main {
+ @include scrollbars(12px);
+ flex: 1 1 auto;
+ z-index:100;
+ overflow-y:scroll;
+ overflow-x:hidden;
+}
diff --git a/app/assets/stylesheets/application/components/_site_header.scss b/app/assets/stylesheets/application/components/_site_header.scss
new file mode 100644
index 0000000..11c83eb
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_site_header.scss
@@ -0,0 +1,137 @@
+.siteHeader {
+ width:100%;
+ background:$blue;
+ flex: 0 0 auto;
+ color:#fff;
+ z-index:1000;
+ border-right:12px solid #efefef;
+
+ .browser-firefox & {
+ border-right: 15px solid #fafafa;
+ }
+}
+
+.siteHeader__inside {
+ display:flex;
+ padding:12px 16px;
+ align-items:center;
+}
+
+.siteHeader__remember {
+ background:$veryDarkBlue;
+ position:fixed;
+ top:20px;
+ right:20px;
+ padding:20px;
+ border-radius:4px;
+ color:#fff;
+ z-index:2000;
+}
+
+.siteHeader__rememberButtons {
+ margin-top:15px;
+}
+
+.siteHeader__rememberText {
+ line-height:1.5;
+ font-size:12px;
+ color:#999;
+}
+
+.siteHeader__rememberTextTitle {
+ font-weight:600;
+ color:#fff;
+ font-size:16px;
+}
+
+.siteHeader__logo {
+ display:block;
+ a {
+ margin:0;
+ font-size:14px;
+ font-weight:600;
+ display:block;
+ }
+}
+
+
+.siteHeader__version {
+ margin-left:5px;
+ color:#fff;
+ opacity:0.3;
+ font-size:12px;
+}
+
+.siteHeader__nav {
+ flex: 1 0 auto;
+ text-align:right;
+ font-size:12px;
+ display:flex;
+ justify-content:flex-end;
+}
+
+.siteHeader__navItem {
+ margin-left:18px;
+}
+
+.sideHeader__navItemLink {
+ text-decoration: underline;
+ opacity: 0.5;
+}
+
+.siteHeader__navLinkWithMenu {
+ background:image-url('icons/drop-arrow-white.svg') no-repeat right 6px / 8px;
+ padding-right:12px;
+}
+
+.siteHeader__navItem--user {
+ background:image-url('icons/user-white.svg') no-repeat 0 3px / 8px;
+ padding-left:13px;
+}
+
+.siteHeader__navItem--organization {
+ background:image-url('icons/organization-white.svg') no-repeat 0 2px / 12px;
+ padding-left:18px;
+}
+
+.siteHeader__subMenu {
+ position:absolute;
+ background:#fff;
+ z-index:1000;
+ color:$darkBlue;
+ text-align:left;
+ box-shadow:0 0 15px rgba(0,0,0,0.2);
+ border-radius:4px;
+ margin-left:-15px;
+ margin-top:-5px;
+ overflow:hidden;
+ display:none;
+}
+
+.siteHeader__navItem:hover .siteHeader__subMenu {
+ display:block;
+}
+
+
+.siteHeader__subMenuItem + .siteHeader__subMenuItem {
+ border-top:1px solid #e6ebf0;
+}
+
+.siteHeader__subMenuItem--header {
+ font-weight:600;
+ padding:5px 15px;
+ background:$lightBlue;
+ color:$blue;
+}
+
+.siteHeader__subMenuLink {
+ padding:10px 15px;
+ display:block;
+ &:hover {
+ background-color:#f2f5f8;
+ }
+}
+
+.siteHeader__subMenuItem--div {
+ border-top-width:2px !important;
+}
diff --git a/app/assets/stylesheets/application/components/_spam_check_list.scss b/app/assets/stylesheets/application/components/_spam_check_list.scss
new file mode 100644
index 0000000..be922f3
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_spam_check_list.scss
@@ -0,0 +1,66 @@
+.spamCheckList {
+ border-radius:4px;
+ overflow:hidden;
+ box-shadow:0 0 10px rgba(0,0,0,0.2);
+}
+
+.spamCheckList__item {
+ display:block;
+ background:#fff;
+ padding:15px;
+ align-items:center;
+ display:flex;
+}
+.spamCheckList__item:nth-child(even) {
+ background:none;
+}
+
+.spamCheckList__item + .spamCheckList__item {
+ border-top:1px solid lighten(#ccd4e0, 10%);
+}
+
+.spamCheckList__score {
+ width:130px;
+ font-size:20px;
+ font-weight:900;
+ text-align:center;
+ flex: 0 0 auto;
+}
+
+.spamCheckList__score--positive {
+ color:$green;
+}
+
+.spamCheckList__score--negative {
+ color:$red;
+}
+
+.spamCheckList__score--neutral {
+ color:$subBlue;
+}
+
+
+
+.spamCheckList__details {
+ flex: 1 1 auto;
+}
+
+.spamCheckList__code {
+ font-family:'Droid Sans Mono';
+ font-size:12px;
+ color:$subBlue;
+ margin-bottom:3px;
+}
+
+.spamCheckList__description {
+ line-height:1.5;
+}
+
+.spamCheckList__item--total + .spamCheckList__item{
+ border-top-width:2px;
+ border-top-color:$subBlue;
+}
+
+.spamCheckList__details--total {
+
+}
diff --git a/app/assets/stylesheets/application/components/_starter_credit_pack.scss b/app/assets/stylesheets/application/components/_starter_credit_pack.scss
new file mode 100644
index 0000000..fac722c
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_starter_credit_pack.scss
@@ -0,0 +1,20 @@
+.starterCreditPack {
+ border-radius:4px;
+ overflow:hidden;
+ box-shadow:0 0 10px rgba(0,0,0,0.2);
+ background:image-url('starter_pack.png') #fff no-repeat 25px 20px;
+ background-size:100px;
+ padding:25px 25px 20px 155px;
+ line-height:1.5;
+}
+
+.starterCreditPack__text {
+ margin-bottom:10px;
+}
+
+.starterCreditPack__nextRenew {
+ font-size:12px;
+ color:#999;
+ margin-left:5px;
+ vertical-align:-2px;
+}
diff --git a/app/assets/stylesheets/application/components/_sub_page_box.scss b/app/assets/stylesheets/application/components/_sub_page_box.scss
new file mode 100644
index 0000000..7edbbfc
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_sub_page_box.scss
@@ -0,0 +1,34 @@
+.subPageBox {
+ background:#fff;
+ border-radius:4px;
+ box-shadow:0 0 30px rgba(0,0,0,0.15);
+ width:300px;
+ margin:auto;
+ overflow:hidden;
+ border-top:5px solid $blue;
+}
+
+.subPageBox--wide {
+ width:500px;
+}
+
+.subPageBox__title {
+ background:$lightBlue;
+ border-bottom:1px solid #d1dcea;
+ padding:20px 25px;
+ color:$blue;
+ font-size:16px;
+ font-weight:600;
+ text-align: center;
+}
+
+.subPageBox__content {
+ padding:20px 25px;
+}
+
+.subPageBox__text {
+ color:$subBlue;
+ font-size:13px;
+ line-height:1.4;
+ margin-bottom:20px;
+}
diff --git a/app/assets/stylesheets/application/components/_suppression_list.scss b/app/assets/stylesheets/application/components/_suppression_list.scss
new file mode 100644
index 0000000..c4ea560
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_suppression_list.scss
@@ -0,0 +1,40 @@
+.suppressionList {
+ border-radius:4px;
+ overflow:hidden;
+ box-shadow:0 0 10px rgba(0,0,0,0.2);
+}
+
+.suppressionList__item {
+ background:#fff;
+ padding:15px;
+ display:flex;
+}
+.suppressionList__item:nth-child(even) {
+ background:none;
+}
+
+.suppressionList__item + .suppressionList__item {
+ border-top:1px solid lighten(#ccd4e0, 10%);
+}
+
+.suppressionList__left {
+ flex: 1 1 auto;
+}
+
+.suppressionList__right {
+ flex: 0 0 auto;
+}
+
+.suppressionList__timestamp {
+ color:#999;
+ font-size:12px;
+}
+
+.suppressionList__address {
+ font-weight:600;
+ margin-bottom:5px;
+}
+
+.suppressionList__reason {
+ color:#999;
+}
diff --git a/app/assets/stylesheets/application/components/_suspension_box.scss b/app/assets/stylesheets/application/components/_suspension_box.scss
new file mode 100644
index 0000000..e0fe4bd
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_suspension_box.scss
@@ -0,0 +1,14 @@
+.suspensionBox {
+ background:#e2383a;
+ border-radius:4px;
+ color:#fff;
+ line-height:1.5;
+ padding:25px;
+ font-size:16px;
+}
+
+.suspensionBox__reason {
+ margin-top:5px;
+ font-size:14px;
+ opacity:0.7;
+}
diff --git a/app/assets/stylesheets/application/components/_title_with_links.scss b/app/assets/stylesheets/application/components/_title_with_links.scss
new file mode 100644
index 0000000..9d00552
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_title_with_links.scss
@@ -0,0 +1,26 @@
+.titleWithLinks {
+ display:flex;
+ color:$darkBlue;
+ align-items:center;
+}
+
+.titleWithLinks__title {
+ flex: 1 1 auto;
+ font-size:23px;
+
+}
+
+.titleWithLinks__links {
+ flex: 1 1 auto;
+ display:flex;
+ justify-content: flex-end;
+ li + li {
+ margin-left:25px;
+ }
+}
+.titleWithLinks__link {
+ text-decoration: underline;
+ &:hover {
+ color:$blue;
+ }
+}
diff --git a/app/assets/stylesheets/application/components/_user_list.scss b/app/assets/stylesheets/application/components/_user_list.scss
new file mode 100644
index 0000000..f6e315b
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_user_list.scss
@@ -0,0 +1,74 @@
+.userList {
+ border-radius:4px;
+ color:$darkBlue;
+ overflow:hidden;
+ box-shadow:0 0 10px rgba(0,0,0,0.2);
+}
+
+.userList__item {
+ display:block;
+ background:#fff;
+ padding:15px;
+ display:flex;
+ align-items: center;
+}
+.userList__item:nth-child(even) {
+ background:none;
+}
+
+.userList__item + .userList__item {
+ border-top:1px solid lighten(#ccd4e0, 10%);
+}
+
+.userList__avatar {
+ width:50px;
+ height:50px;
+ border-radius:50%;
+ background:#fff;
+ border:2px solid #efefef;
+ padding:3px;
+ flex: 0 0 auto;
+}
+
+.userList__details {
+ flex: 1 1 auto;
+ margin:0 25px;
+}
+
+
+.userList__actions {
+ flex: 0 0 auto;
+ width:180px;
+ font-size:12px;
+ line-height:1.5;
+ color:#999;
+ text-decoration: underline;
+}
+
+.userList__name {
+ font-weight:600;
+ font-size:16px;
+ margin-bottom:3px;
+}
+
+.userList__owner {
+ vertical-align:2px;
+ margin-left:5px;
+ background-color:$orange;
+}
+
+.userList__pending {
+ vertical-align:2px;
+ margin-left:5px;
+ background-color:#ccc;
+}
+
+.userList__admin {
+ vertical-align:2px;
+ margin-left:5px;
+ background-color:$blue;
+}
+
+.userList__revoke {
+ color:$red;
+}
diff --git a/app/assets/stylesheets/application/components/_webhook_list.scss b/app/assets/stylesheets/application/components/_webhook_list.scss
new file mode 100644
index 0000000..4abddec
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_webhook_list.scss
@@ -0,0 +1,68 @@
+.webhookList {
+ border-radius:4px;
+ overflow:hidden;
+ box-shadow:0 0 10px rgba(0,0,0,0.2);
+}
+
+.webhookList__item {
+ background:#fff;
+ padding:15px;
+}
+.webhookList__item:nth-child(even) {
+ background:none;
+}
+
+.webhookList__item + .webhookList__item {
+ border-top:1px solid lighten(#ccd4e0, 10%);
+}
+
+.webhookList__top {
+ display:flex;
+ align-items: center;
+ min-width:1px;
+}
+
+.webhookList__labels {
+ flex: 0 0 auto;
+ line-height:0;
+ margin-left:10px;
+ .label + .label {
+ margin-left:2px;
+ }
+}
+
+.webhookList__name {
+ font-weight:600;
+ flex: 1 1 auto;
+ overflow:hidden;
+ text-overflow:ellipsis;
+ line-height:1.4;
+}
+
+.webhookList__bottom {
+ display:flex;
+ margin-top:3px;
+ font-size:12px;
+}
+
+.webhookList__usageTime {
+ color:#999;
+ line-height:1.4;
+ flex: 1 1 auto;
+}
+
+.webhookList__links {
+ flex: 0 0 auto;
+ display:flex;
+}
+
+.webhookList__link {
+ a {
+ color:#999;
+ text-decoration: underline;
+ }
+}
+
+.webhookList__link + .webhookList__link {
+ margin-left:12px;
+}
diff --git a/app/assets/stylesheets/application/components/_webhook_request_list.scss b/app/assets/stylesheets/application/components/_webhook_request_list.scss
new file mode 100644
index 0000000..e0108a0
--- /dev/null
+++ b/app/assets/stylesheets/application/components/_webhook_request_list.scss
@@ -0,0 +1,59 @@
+.webhookRequestList {
+ border-radius:4px;
+ overflow:hidden;
+ box-shadow:0 0 10px rgba(0,0,0,0.2);
+}
+
+.webhookRequestList__item {
+ background:#fff;
+}
+.webhookRequestList__item:nth-child(even) {
+ background:none;
+}
+
+.webhookRequestList__item + .webhookRequestList__item {
+ border-top:1px solid lighten(#ccd4e0, 10%);
+}
+
+.webhookRequestList__link {
+ display:block;
+ padding:15px;
+ &:hover {
+ background:#f2f5f8 !important;
+ }
+}
+
+.webhookRequestList__top {
+ display:flex;
+ margin-bottom:6px;
+ font-size:12px;
+}
+
+.webhookRequestList__status {
+ margin-top:-1px;
+ margin-right:10px;
+}
+
+.webhookRequestList__time {
+ flex: 1 1 auto;
+ color:#999;
+}
+
+.webhookRequestList__event {
+ flex: 0 0 auto;
+ font-size:11px;
+ border:1px solid $subBlue;
+ color:$subBlue;
+ border-radius:3px;
+ padding:3px 6px;
+ margin-top:-2px;
+}
+
+
+.webhookRequestList__url {
+ flex: 1 1 auto;
+ overflow:hidden;
+ font-family:'Droid Sans Mono', fixed;
+ line-height:1.4;
+ text-overflow:ellipsis;
+}
diff --git a/app/assets/stylesheets/application/elements/_bar.scss b/app/assets/stylesheets/application/elements/_bar.scss
new file mode 100644
index 0000000..af7f622
--- /dev/null
+++ b/app/assets/stylesheets/application/elements/_bar.scss
@@ -0,0 +1,16 @@
+.bar {
+ background:$darkBlue;
+ border-radius:10px;
+ display:inline-block;
+ height:5px;
+ width:100%;
+ overflow:hidden;
+}
+
+.bar__inner {
+ background:$blue;
+ display:inline-block;
+ border-radius:10px;
+ height:5px;
+ width:50%;
+}
diff --git a/app/assets/stylesheets/application/elements/_button.scss b/app/assets/stylesheets/application/elements/_button.scss
new file mode 100644
index 0000000..b66116a
--- /dev/null
+++ b/app/assets/stylesheets/application/elements/_button.scss
@@ -0,0 +1,98 @@
+.button {
+ display:inline-block;
+ font:inherit;
+ border-radius:4px;
+ appearance:none;
+ background:$blue;
+ color:#fff;
+ font-size:14px !important;
+ margin:0;
+ vertical-align:top;
+ padding:6px 15px;
+ border:2px solid transparent;
+ border-bottom:2px solid darken($blue, 20%);
+ &:active {
+ background-color:darken($blue, 15%);
+ }
+ &:focus {
+ border-color:darken($blue, 15%);
+ background-color:lighten($blue, 5%);
+ }
+ &.is-spinning {
+ color:transparent;
+ background-repeat:no-repeat;
+ background-position: center center;
+ background-size:25px;
+ background-image:image-url('button-spinner.gif');
+ }
+}
+
+.button--small {
+ font-size:12px !important;
+ padding:3px 10px;
+ border-width:1px;
+}
+
+.button--positive {
+ background-color:$green;
+ border-bottom-color:darken($green, 15%);
+ &:active {
+ background-color:darken($green, 15%);
+ }
+ &:focus {
+ border-color:darken($green, 15%);
+ background-color:lighten($green, 5%);
+ }
+ &.is-spinning {
+ background-image:image-url('button-spinner-positive.gif');
+ }
+
+}
+
+
+.button--neutral {
+ background-color:#ccc;
+ border-bottom-color:darken(#ccc, 15%);
+ &:active {
+ background-color:darken(#ccc, 15%);
+ }
+ &:focus {
+ border-color:darken(#ccc, 15%);
+ background-color:lighten(#ccc, 5%);
+ }
+ &.is-spinning {
+ background-image:image-url('button-spinner-neutral.gif');
+ }
+}
+
+.button--danger {
+ background-color:$red;
+ border-bottom-color:darken($red, 15%);
+ &:active {
+ background-color:darken($red, 15%);
+ }
+ &:focus {
+ border-color:darken($red, 15%);
+ background-color:lighten($red, 5%);
+ }
+ &.is-spinning {
+ background-image:image-url('button-spinner-danger.gif');
+ }
+}
+
+.button--dark {
+ background-color:$darkBlue;
+ border-bottom-color:darken($darkBlue, 15%);
+ &:active {
+ background-color:darken($darkBlue, 15%);
+ }
+ &:focus {
+ border-color:darken($darkBlue, 15%);
+ background-color:lighten($darkBlue, 5%);
+ }
+ &.is-spinning {
+ background-image:image-url('button-spinner-dark.gif');
+ }
+
+}
+
diff --git a/app/assets/stylesheets/application/elements/_code_block.scss b/app/assets/stylesheets/application/elements/_code_block.scss
new file mode 100644
index 0000000..eb9d80a
--- /dev/null
+++ b/app/assets/stylesheets/application/elements/_code_block.scss
@@ -0,0 +1,11 @@
+.codeBlock {
+ background:#909db0;
+ color:#fff;
+ padding:25px;
+ border-radius:4px;
+}
+
+.codeBlock--whitespace {
+ white-space:pre;
+ overflow-x:auto;
+}
diff --git a/app/assets/stylesheets/application/elements/_input.scss b/app/assets/stylesheets/application/elements/_input.scss
new file mode 100644
index 0000000..33a49b0
--- /dev/null
+++ b/app/assets/stylesheets/application/elements/_input.scss
@@ -0,0 +1,70 @@
+.input {
+ border:0;
+ padding:0;
+ margin:0;
+ background:#fff;
+ border:1px solid #e4e8ef;
+ padding:8px 10px;
+ font:inherit;
+ width:100%;
+ color:$darkBlue;
+ font-weight:600;
+ appearance:none;
+ border-radius:4px;
+}
+
+.input--onWhite {
+ background-color:$backgroundGrey;
+}
+
+.input:disabled, .input.is-disabled {
+ opacity:0.5;
+}
+
+.input:focus {
+ border-color:$blue;
+ background-color:#fff;
+}
+
+.input--danger {
+ color:$red;
+ border-width:2px;
+ border-color:lighten($red, 37%);
+}
+
+.input--area {
+ height:300px;
+}
+
+.input--smallArea {
+ height:120px;
+}
+
+.input--danger:focus {
+ border-color:$red;
+ color:$red;
+ background:#fff;
+}
+
+.input::placeholder {
+ color:#b5c0d0;
+ font-weight:400;
+}
+
+.input--select {
+ background: #fff image-url('icons/select-arrow.svg') right 12px top 50% / 16px 16px no-repeat;
+ cursor: pointer;
+}
+
+.input--code {
+ font-family:'Droid Sans Mono', fixed;
+ font-size:13px;
+}
+
+.inputPair {
+ display:flex;
+ justify-content: space-between;
+ .input {
+ width:49%;
+ }
+}
diff --git a/app/assets/stylesheets/application/elements/_label.scss b/app/assets/stylesheets/application/elements/_label.scss
new file mode 100644
index 0000000..8899f15
--- /dev/null
+++ b/app/assets/stylesheets/application/elements/_label.scss
@@ -0,0 +1,132 @@
+.label {
+ display:inline-block;
+ background:#000;
+ color:#fff;
+ font-size:9px;
+ text-transform: uppercase;
+ border-radius:40px;
+ padding:2px 6px;
+ line-height:0.9;
+}
+
+.label--green {
+ background-color:$green;
+}
+
+.label--red {
+ background-color:$red;
+}
+
+.label--orange {
+ background-color:$orange;
+}
+
+.label--blue {
+ background-color:$blue;
+}
+
+.label--grey {
+ background-color:#999;
+}
+
+.label--turquoise {
+ background-color:$blue;
+}
+
+.label--purple {
+ background-color:$purple;
+}
+
+.label--large {
+ font-size:11px;
+ padding:4px 10px;
+}
+
+.label--serverStatus-live {
+ background-color:$green;
+}
+
+.label--serverStatus-development {
+ background-color:#636363;
+}
+
+.label--serverStatus-suspended {
+ background-color:$red;
+}
+
+.label--messageStatus-pending {
+ background-color:$subBlue;
+}
+
+.label--messageStatus-held {
+ background-color:#aaa;
+}
+
+.label--messageStatus-processed {
+ background-color:$green;
+}
+
+.label--messageStatus-sent {
+ background-color:$green;
+}
+
+.label--messageStatus-hard_fail {
+ background-color:$red;
+}
+
+.label--messageStatus-soft_fail {
+ background-color:$orange;
+}
+
+.label--messageStatus-bounced {
+ background-color:$red;
+}
+
+.label--messageStatus-hold_cancelled {
+ background-color:#ccc;
+}
+
+
+.label--credentialType-api {
+ background-color:$blue;
+}
+
+.label--credentialType-smtp {
+ background-color:$turquoise;
+}
+
+.label--spamStatus-not_checked {
+ background:#aaa;
+}
+
+.label--spamStatus-spam {
+ background:$orange;
+}
+
+.label--spamStatus-not_spam {
+ background:$turquoise;
+}
+
+.label--http-status-2 {
+ background-color:$green;
+}
+
+.label--http-status-3 {
+ background-color:$orange;
+}
+
+.label--http-status-4,
+.label--http-status-5 {
+ background-color:$red;
+}
+
+.domainList__ssl {
+ color:$green;
+ &:hover {
+ text-decoration:underline;
+ }
+}
+
+.domainList__ssl--disabled {
+ color:#999;
+}
diff --git a/app/assets/stylesheets/application/elements/_misc.scss b/app/assets/stylesheets/application/elements/_misc.scss
new file mode 100644
index 0000000..114c840
--- /dev/null
+++ b/app/assets/stylesheets/application/elements/_misc.scss
@@ -0,0 +1,16 @@
+.returnPathTag {
+ background:image-url('icons/return-path.svg') no-repeat 0 4px / 10px;
+ padding-left:14px;
+}
+
+.returnPathTag--inMessageHeader {
+ background-size:14px;
+ padding-left:18px;
+}
+
+.warningBox {
+ background-color:#fff8e4;
+ border:1px solid #c8bc9b;
+ padding:15px;
+ line-height:1.4;
+}
diff --git a/app/assets/stylesheets/application/elements/_spam_range.scss b/app/assets/stylesheets/application/elements/_spam_range.scss
new file mode 100644
index 0000000..9d87259
--- /dev/null
+++ b/app/assets/stylesheets/application/elements/_spam_range.scss
@@ -0,0 +1,159 @@
+.spamRangeLabel {
+ font-size:12px;
+ text-align:right;
+ margin-top:7px;
+}
+
+.spamRange {
+ -webkit-appearance: none; /* Hides the slider so that custom slider can be made */
+ width: 100%; /* Specific width is required for Firefox. */
+ background: transparent; /* Otherwise white in Chrome */
+ &:disabled {
+ opacity:0.5;
+ }
+}
+
+.spamRange::-webkit-slider-thumb {
+ -webkit-appearance: none;
+}
+
+.spamRange:focus {
+ outline: none; /* Removes the blue border. You should probably do some kind of focus styling for accessibility reasons though. */
+}
+
+.spamRange::-ms-track {
+ width: 100%;
+ cursor: pointer;
+
+ /* Hides the slider so custom styles can be added */
+ background: transparent;
+ border-color: transparent;
+ color: transparent;
+}
+
+/* Special styling for WebKit/Blink */
+.spamRange::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ border: 2px solid #2b2e32;
+ height:25px;
+ width:25px;
+ border-radius: 50%;
+ background: #ffffff;
+ cursor: pointer;
+ margin-top: -7px; /* You need to specify a margin in Chrome, but in Firefox and IE it is automatic */
+}
+
+/* All the same stuff for Firefox */
+.spamRange::-moz-range-thumb {
+ border: 2px solid #2b2e32;
+ height:25px;
+ width:25px;
+ border-radius: 50%;
+ background: #ffffff;
+ cursor: pointer;
+}
+
+/* All the same stuff for IE */
+.spamRange::-ms-thumb {
+ border: 2px solid #2b2e32;
+ height:25px;
+ width:25px;
+ border-radius: 50%;
+ background: #ffffff;
+ cursor: pointer;
+}
+
+//
+// Track
+//
+
+.spamRange::-webkit-slider-runnable-track {
+ width: 100%;
+ height: 12px;
+ cursor: pointer;
+ border-radius:30px;
+ background: #3ff990; /* Old browsers */
+ background: -moz-linear-gradient(left, #3ff990 0%, #197ec9 47%, #6c5c8b 100%); /* FF3.6-15 */
+ background: -webkit-linear-gradient(left, #3ff990 0%,#197ec9 47%,#6c5c8b 100%); /* Chrome10-25,Safari5.1-6 */
+ background: linear-gradient(to right, #3ff990 0%,#197ec9 47%,#6c5c8b 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
+ filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#3ff990', endColorstr='#6c5c8b',GradientType=1 ); /* IE6-9 */
+}
+
+.spamRange::-moz-range-track {
+ width: 100%;
+ height: 12px;
+ cursor: pointer;
+ border-radius:30px;
+ background: #3ff990; /* Old browsers */
+ background: -moz-linear-gradient(left, #3ff990 0%, #197ec9 47%, #6c5c8b 100%); /* FF3.6-15 */
+ background: -webkit-linear-gradient(left, #3ff990 0%,#197ec9 47%,#6c5c8b 100%); /* Chrome10-25,Safari5.1-6 */
+ background: linear-gradient(to right, #3ff990 0%,#197ec9 47%,#6c5c8b 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
+ filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#3ff990', endColorstr='#6c5c8b',GradientType=1 ); /* IE6-9 */
+}
+
+.spamRange::-ms-track {
+ width: 100%;
+ height: 12px;
+ cursor: pointer;
+ border-radius:30px;
+ background: transparent;
+ border-color: transparent;
+ background: #3ff990; /* Old browsers */
+ background: -moz-linear-gradient(left, #3ff990 0%, #197ec9 47%, #6c5c8b 100%); /* FF3.6-15 */
+ background: -webkit-linear-gradient(left, #3ff990 0%,#197ec9 47%,#6c5c8b 100%); /* Chrome10-25,Safari5.1-6 */
+ background: linear-gradient(to right, #3ff990 0%,#197ec9 47%,#6c5c8b 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
+ filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#3ff990', endColorstr='#6c5c8b',GradientType=1 ); /* IE6-9 */
+}
+
+.spamRange--hot::-webkit-slider-runnable-track {
+ background: #1688d0; /* Old browsers */
+ background: -moz-linear-gradient(left, #1688d0 0%, #fa141b 100%); /* FF3.6-15 */
+ background: -webkit-linear-gradient(left, #1688d0 0%,#fa141b 100%); /* Chrome10-25,Safari5.1-6 */
+ background: linear-gradient(to right, #1688d0 0%,#fa141b 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
+ filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#1688d0', endColorstr='#fa141b',GradientType=1 ); /* IE6-9 */
+}
+
+.spamRange--hot::-moz-range-track {
+ background: #1688d0; /* Old browsers */
+ background: -moz-linear-gradient(left, #1688d0 0%, #fa141b 100%); /* FF3.6-15 */
+ background: -webkit-linear-gradient(left, #1688d0 0%,#fa141b 100%); /* Chrome10-25,Safari5.1-6 */
+ background: linear-gradient(to right, #1688d0 0%,#fa141b 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
+ filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#1688d0', endColorstr='#fa141b',GradientType=1 ); /* IE6-9 */
+}
+
+.spamRange--hot::-ms-track {
+ background: #1688d0; /* Old browsers */
+ background: -moz-linear-gradient(left, #1688d0 0%, #fa141b 100%); /* FF3.6-15 */
+ background: -webkit-linear-gradient(left, #1688d0 0%,#fa141b 100%); /* Chrome10-25,Safari5.1-6 */
+ background: linear-gradient(to right, #1688d0 0%,#fa141b 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
+ filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#1688d0', endColorstr='#fa141b',GradientType=1 ); /* IE6-9 */
+}
+
+
+.spamRange--blueGreen::-webkit-slider-runnable-track {
+ /* Permalink - use to edit and share this gradient: http://colorzilla.com/gradient-editor/#146dd2+0,7cc546+100 */
+ background: #146dd2; /* Old browsers */
+ background: -moz-linear-gradient(left, #146dd2 0%, #7cc546 100%); /* FF3.6-15 */
+ background: -webkit-linear-gradient(left, #146dd2 0%,#7cc546 100%); /* Chrome10-25,Safari5.1-6 */
+ background: linear-gradient(to right, #146dd2 0%,#7cc546 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
+ filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#146dd2', endColorstr='#7cc546',GradientType=1 ); /* IE6-9 */
+}
+
+.spamRange--blueGreen::-moz-range-track {
+ /* Permalink - use to edit and share this gradient: http://colorzilla.com/gradient-editor/#146dd2+0,7cc546+100 */
+ background: #146dd2; /* Old browsers */
+ background: -moz-linear-gradient(left, #146dd2 0%, #7cc546 100%); /* FF3.6-15 */
+ background: -webkit-linear-gradient(left, #146dd2 0%,#7cc546 100%); /* Chrome10-25,Safari5.1-6 */
+ background: linear-gradient(to right, #146dd2 0%,#7cc546 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
+ filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#146dd2', endColorstr='#7cc546',GradientType=1 ); /* IE6-9 */
+}
+
+.spamRange--blueGreen::-ms-track {
+ /* Permalink - use to edit and share this gradient: http://colorzilla.com/gradient-editor/#146dd2+0,7cc546+100 */
+ background: #146dd2; /* Old browsers */
+ background: -moz-linear-gradient(left, #146dd2 0%, #7cc546 100%); /* FF3.6-15 */
+ background: -webkit-linear-gradient(left, #146dd2 0%,#7cc546 100%); /* Chrome10-25,Safari5.1-6 */
+ background: linear-gradient(to right, #146dd2 0%,#7cc546 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
+ filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#146dd2', endColorstr='#7cc546',GradientType=1 ); /* IE6-9 */
+}
+
diff --git a/app/assets/stylesheets/application/global/_fonts.scss b/app/assets/stylesheets/application/global/_fonts.scss
new file mode 100644
index 0000000..dcfc57f
--- /dev/null
+++ b/app/assets/stylesheets/application/global/_fonts.scss
@@ -0,0 +1,59 @@
+@font-face {
+ font-family: "Droid Sans Mono";
+ src: font-url("DroidSansMono.eot");
+ src: font-url("DroidSansMono.eot?#iefix") format("embedded-opentype"),
+ font-url("DroidSansMono.woff") format("woff"),
+ font-url("DroidSansMono.ttf") format("truetype");
+ font-weight: normal;
+ font-style: normal;
+}
+
+@font-face {
+ font-family: "Source Sans Pro";
+ src: font-url("SourceSansPro-Light.eot");
+ src: font-url("SourceSansPro-Light.eot?#iefix") format("embedded-opentype"),
+ font-url("SourceSansPro-Light.woff") format("woff"),
+ font-url("SourceSansPro-Light.ttf") format("truetype");
+ font-weight: 300;
+ font-style: normal;
+}
+
+@font-face {
+ font-family: "Source Sans Pro";
+ src: font-url("SourceSansPro-Regular.eot");
+ src: font-url("SourceSansPro-Regular.eot?#iefix") format("embedded-opentype"),
+ font-url("SourceSansPro-Regular.woff") format("woff"),
+ font-url("SourceSansPro-Regular.ttf") format("truetype");
+ font-weight: normal;
+ font-style: normal;
+}
+
+@font-face {
+ font-family: "Source Sans Pro";
+ src: font-url("SourceSansPro-Semibold.eot");
+ src: font-url("SourceSansPro-Semibold.eot?#iefix") format("embedded-opentype"),
+ font-url("SourceSansPro-Semibold.woff") format("woff"),
+ font-url("SourceSansPro-Semibold.ttf") format("truetype");
+ font-weight: 600;
+ font-style: normal;
+}
+
+@font-face {
+ font-family: "Source Sans Pro";
+ src: font-url("SourceSansPro-Bold.eot");
+ src: font-url("SourceSansPro-Bold.eot?#iefix") format("embedded-opentype"),
+ font-url("SourceSansPro-Bold.woff") format("woff"),
+ font-url("SourceSansPro-Bold.ttf") format("truetype");
+ font-weight: bold;
+ font-style: normal;
+}
+
+@font-face {
+ font-family: "Source Sans Pro";
+ src: font-url("SourceSansPro-Black.eot");
+ src: font-url("SourceSansPro-Black.eot?#iefix") format("embedded-opentype"),
+ font-url("SourceSansPro-Black.woff") format("woff"),
+ font-url("SourceSansPro-Black.ttf") format("truetype");
+ font-weight: 900;
+ font-style: normal;
+}
diff --git a/app/assets/stylesheets/application/global/_mixins.scss b/app/assets/stylesheets/application/global/_mixins.scss
new file mode 100644
index 0000000..41165a5
--- /dev/null
+++ b/app/assets/stylesheets/application/global/_mixins.scss
@@ -0,0 +1,14 @@
+@mixin scrollbars($size: 6px, $thumb: #979ea6, $track: #efefef) {
+ &::-webkit-scrollbar {
+ height: $size; // Horizontal Scrollbars
+ width: $size; // Vertical Scrollbars
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: $thumb;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: $track;
+ }
+}
diff --git a/app/assets/stylesheets/application/global/_reset.scss b/app/assets/stylesheets/application/global/_reset.scss
new file mode 100644
index 0000000..b21fbbf
--- /dev/null
+++ b/app/assets/stylesheets/application/global/_reset.scss
@@ -0,0 +1,113 @@
+html, body, body div, span, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, abbr, address, cite, code, del, dfn, em, img, ins, kbd, q, samp, small, strong, sub, sup, var, b, i, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, figure, footer, header, hgroup, menu, nav, section, time, mark, audio, video {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ outline: 0;
+ font-weight: normal;
+ font-size: 100%;
+ letter-spacing:0;
+ vertical-align: baseline;
+ background: transparent;
+}
+
+span {
+ font-weight:inherit;
+}
+
+article, aside, figure, footer, header, hgroup, nav, section {display: block;}
+
+img,object,embed {max-width: 100%;}
+ul {list-style: none;}
+blockquote, q {quotes: none;}
+b,strong { font-weight:bold;}
+strong.semi { font-weight:600;}
+blockquote:before, blockquote:after, q:before, q:after {content: ''; content: none;}
+
+a {margin: 0; padding: 0; font-size: 100%; vertical-align: baseline; background: transparent; color:inherit; text-decoration: none; line-height:1; margin:0 }
+
+del {text-decoration: line-through;}
+
+abbr[title], dfn[title] {border-bottom: 1px dotted #000; cursor: help;}
+
+/* tables still need cellspacing="0" in the markup */
+table {border-collapse: collapse; border-spacing: 0;}
+th {font-weight: bold; vertical-align: bottom;}
+td {font-weight: normal; vertical-align: top;}
+
+hr {display: block; height: 1px; border: 0; border-top: 3px solid #ddd; margin:0; padding: 0;}
+
+input, select {vertical-align: middle;}
+
+pre {
+ white-space: pre; /* CSS2 */
+ white-space: pre-wrap; /* CSS 2.1 */
+ white-space: pre-line; /* CSS 3 (and 2.1 as well, actually) */
+ word-wrap: break-word; /* IE */
+}
+
+input[type="radio"] {vertical-align: text-bottom;}
+input[type="checkbox"] {vertical-align: bottom; *vertical-align: baseline;}
+.ie6 input {vertical-align: text-bottom;}
+
+select, input, textarea {font: 99% sans-serif;}
+
+table {font: inherit;}
+
+/* Accessible focus treatment
+ people.opera.com/patrickl/experiments/keyboard/test */
+a:hover, a:active {outline: none;}
+
+small {font-size: 85%;}
+
+strong, th {font-weight: bold;}
+
+td, td img {vertical-align: top;}
+
+/* Make sure sup and sub don't screw with your line-heights
+ gist.github.com/413930 */
+sub, sup {font-size: 75%; line-height: 0; position: relative;}
+sup {top: -0.5em;}
+sub {bottom: -0.25em;}
+
+/* standardize any monospaced elements */
+pre, code, kbd, samp {font-family: 'Droid Sans Mono', fixed;}
+
+/* hand cursor on clickable elements */
+label,
+input[type=button],
+input[type=submit],
+button {
+ cursor: pointer;
+}
+
+button, input, select, textarea {
+ margin: 0;
+}
+
+button {
+ width: auto;
+ overflow: visible;
+ appearance: none;
+}
+
+select, input, textarea, a, button {
+ outline: none;
+}
+
+*, *:before, *:after {
+ box-sizing: border-box;
+}
+
+address {
+ font-style: normal;
+}
+
+th {
+ font-weight: initial;
+ text-align: left;
+}
+
+img {
+ border: 0;
+}
+
diff --git a/app/assets/stylesheets/application/global/_utility.scss b/app/assets/stylesheets/application/global/_utility.scss
new file mode 100644
index 0000000..c1db584
--- /dev/null
+++ b/app/assets/stylesheets/application/global/_utility.scss
@@ -0,0 +1,34 @@
+.u-margin {
+ margin-bottom:25px;
+}
+
+.u-center {
+ text-align:center;
+}
+
+.u-green {
+ color:$green;
+}
+
+.u-orange {
+ color:$orange;
+}
+
+.u-grey {
+ color:#999;
+}
+.u-red {
+ color:$red;
+}
+
+.u-bold {
+ font-weight:600;
+}
+
+.u-link {
+ text-decoration: underline;
+}
+
+.is-hidden {
+ display:none;
+}
diff --git a/app/assets/stylesheets/application/global/_variables.scss b/app/assets/stylesheets/application/global/_variables.scss
new file mode 100644
index 0000000..bf60410
--- /dev/null
+++ b/app/assets/stylesheets/application/global/_variables.scss
@@ -0,0 +1,28 @@
+$backgroundGrey: #fafafa;
+$blue: #0e69d5;
+$darkBlue: #3c4249;
+$veryDarkBlue: #2b2e32;
+$lightBlue: #eaf3fe;
+$subBlue: #909db0;
+$red: #e2383a;
+$green: #76c83b;
+$orange: #e8581f;
+$turquoise: #4ac7c5;
+$purple: #6145b2;
+
+@mixin clearfix {
+ &:after {
+ clear: both;
+ content: " ";
+ display: table;
+ }
+}
+
+@mixin noselect {
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
diff --git a/app/assets/stylesheets/application/vendor/_chartist.scss b/app/assets/stylesheets/application/vendor/_chartist.scss
new file mode 100644
index 0000000..c84c5ff
--- /dev/null
+++ b/app/assets/stylesheets/application/vendor/_chartist.scss
@@ -0,0 +1,608 @@
+.ct-label {
+ fill: rgba(0, 0, 0, 0.4);
+ color: rgba(0, 0, 0, 0.4);
+ font-size: 0.75rem;
+ line-height: 1; }
+
+.ct-chart-line .ct-label,
+.ct-chart-bar .ct-label {
+ display: block;
+ display: -webkit-box;
+ display: -moz-box;
+ display: -ms-flexbox;
+ display: -webkit-flex;
+ display: flex; }
+
+.ct-label.ct-horizontal.ct-start {
+ -webkit-box-align: flex-end;
+ -webkit-align-items: flex-end;
+ -ms-flex-align: flex-end;
+ align-items: flex-end;
+ -webkit-box-pack: flex-start;
+ -webkit-justify-content: flex-start;
+ -ms-flex-pack: flex-start;
+ justify-content: flex-start;
+ text-align: left;
+ text-anchor: start; }
+
+.ct-label.ct-horizontal.ct-end {
+ -webkit-box-align: flex-start;
+ -webkit-align-items: flex-start;
+ -ms-flex-align: flex-start;
+ align-items: flex-start;
+ -webkit-box-pack: flex-start;
+ -webkit-justify-content: flex-start;
+ -ms-flex-pack: flex-start;
+ justify-content: flex-start;
+ text-align: left;
+ text-anchor: start; }
+
+.ct-label.ct-vertical.ct-start {
+ -webkit-box-align: flex-end;
+ -webkit-align-items: flex-end;
+ -ms-flex-align: flex-end;
+ align-items: flex-end;
+ -webkit-box-pack: flex-end;
+ -webkit-justify-content: flex-end;
+ -ms-flex-pack: flex-end;
+ justify-content: flex-end;
+ text-align: right;
+ text-anchor: end; }
+
+.ct-label.ct-vertical.ct-end {
+ -webkit-box-align: flex-end;
+ -webkit-align-items: flex-end;
+ -ms-flex-align: flex-end;
+ align-items: flex-end;
+ -webkit-box-pack: flex-start;
+ -webkit-justify-content: flex-start;
+ -ms-flex-pack: flex-start;
+ justify-content: flex-start;
+ text-align: left;
+ text-anchor: start; }
+
+.ct-chart-bar .ct-label.ct-horizontal.ct-start {
+ -webkit-box-align: flex-end;
+ -webkit-align-items: flex-end;
+ -ms-flex-align: flex-end;
+ align-items: flex-end;
+ -webkit-box-pack: center;
+ -webkit-justify-content: center;
+ -ms-flex-pack: center;
+ justify-content: center;
+ text-align: center;
+ text-anchor: start; }
+
+.ct-chart-bar .ct-label.ct-horizontal.ct-end {
+ -webkit-box-align: flex-start;
+ -webkit-align-items: flex-start;
+ -ms-flex-align: flex-start;
+ align-items: flex-start;
+ -webkit-box-pack: center;
+ -webkit-justify-content: center;
+ -ms-flex-pack: center;
+ justify-content: center;
+ text-align: center;
+ text-anchor: start; }
+
+.ct-chart-bar.ct-horizontal-bars .ct-label.ct-horizontal.ct-start {
+ -webkit-box-align: flex-end;
+ -webkit-align-items: flex-end;
+ -ms-flex-align: flex-end;
+ align-items: flex-end;
+ -webkit-box-pack: flex-start;
+ -webkit-justify-content: flex-start;
+ -ms-flex-pack: flex-start;
+ justify-content: flex-start;
+ text-align: left;
+ text-anchor: start; }
+
+.ct-chart-bar.ct-horizontal-bars .ct-label.ct-horizontal.ct-end {
+ -webkit-box-align: flex-start;
+ -webkit-align-items: flex-start;
+ -ms-flex-align: flex-start;
+ align-items: flex-start;
+ -webkit-box-pack: flex-start;
+ -webkit-justify-content: flex-start;
+ -ms-flex-pack: flex-start;
+ justify-content: flex-start;
+ text-align: left;
+ text-anchor: start; }
+
+.ct-chart-bar.ct-horizontal-bars .ct-label.ct-vertical.ct-start {
+ -webkit-box-align: center;
+ -webkit-align-items: center;
+ -ms-flex-align: center;
+ align-items: center;
+ -webkit-box-pack: flex-end;
+ -webkit-justify-content: flex-end;
+ -ms-flex-pack: flex-end;
+ justify-content: flex-end;
+ text-align: right;
+ text-anchor: end; }
+
+.ct-chart-bar.ct-horizontal-bars .ct-label.ct-vertical.ct-end {
+ -webkit-box-align: center;
+ -webkit-align-items: center;
+ -ms-flex-align: center;
+ align-items: center;
+ -webkit-box-pack: flex-start;
+ -webkit-justify-content: flex-start;
+ -ms-flex-pack: flex-start;
+ justify-content: flex-start;
+ text-align: left;
+ text-anchor: end; }
+
+.ct-grid {
+ stroke: rgba(0, 0, 0, 0.2);
+ stroke-width: 1px;
+ stroke-dasharray: 2px; }
+
+.ct-point {
+ stroke-width: 10px;
+ stroke-linecap: round; }
+
+.ct-line {
+ fill: none;
+ stroke-width: 4px; }
+
+.ct-area {
+ stroke: none;
+ fill-opacity: 0.1; }
+
+.ct-bar {
+ fill: none;
+ stroke-width: 10px; }
+
+.ct-slice-donut {
+ fill: none;
+ stroke-width: 60px; }
+
+.ct-series-a .ct-point, .ct-series-a .ct-line, .ct-series-a .ct-bar, .ct-series-a .ct-slice-donut {
+ stroke: #d70206; }
+
+.ct-series-a .ct-slice-pie, .ct-series-a .ct-area {
+ fill: #d70206; }
+
+.ct-series-b .ct-point, .ct-series-b .ct-line, .ct-series-b .ct-bar, .ct-series-b .ct-slice-donut {
+ stroke: #f05b4f; }
+
+.ct-series-b .ct-slice-pie, .ct-series-b .ct-area {
+ fill: #f05b4f; }
+
+.ct-series-c .ct-point, .ct-series-c .ct-line, .ct-series-c .ct-bar, .ct-series-c .ct-slice-donut {
+ stroke: #f4c63d; }
+
+.ct-series-c .ct-slice-pie, .ct-series-c .ct-area {
+ fill: #f4c63d; }
+
+.ct-series-d .ct-point, .ct-series-d .ct-line, .ct-series-d .ct-bar, .ct-series-d .ct-slice-donut {
+ stroke: #d17905; }
+
+.ct-series-d .ct-slice-pie, .ct-series-d .ct-area {
+ fill: #d17905; }
+
+.ct-series-e .ct-point, .ct-series-e .ct-line, .ct-series-e .ct-bar, .ct-series-e .ct-slice-donut {
+ stroke: #453d3f; }
+
+.ct-series-e .ct-slice-pie, .ct-series-e .ct-area {
+ fill: #453d3f; }
+
+.ct-series-f .ct-point, .ct-series-f .ct-line, .ct-series-f .ct-bar, .ct-series-f .ct-slice-donut {
+ stroke: #59922b; }
+
+.ct-series-f .ct-slice-pie, .ct-series-f .ct-area {
+ fill: #59922b; }
+
+.ct-series-g .ct-point, .ct-series-g .ct-line, .ct-series-g .ct-bar, .ct-series-g .ct-slice-donut {
+ stroke: #0544d3; }
+
+.ct-series-g .ct-slice-pie, .ct-series-g .ct-area {
+ fill: #0544d3; }
+
+.ct-series-h .ct-point, .ct-series-h .ct-line, .ct-series-h .ct-bar, .ct-series-h .ct-slice-donut {
+ stroke: #6b0392; }
+
+.ct-series-h .ct-slice-pie, .ct-series-h .ct-area {
+ fill: #6b0392; }
+
+.ct-series-i .ct-point, .ct-series-i .ct-line, .ct-series-i .ct-bar, .ct-series-i .ct-slice-donut {
+ stroke: #f05b4f; }
+
+.ct-series-i .ct-slice-pie, .ct-series-i .ct-area {
+ fill: #f05b4f; }
+
+.ct-series-j .ct-point, .ct-series-j .ct-line, .ct-series-j .ct-bar, .ct-series-j .ct-slice-donut {
+ stroke: #dda458; }
+
+.ct-series-j .ct-slice-pie, .ct-series-j .ct-area {
+ fill: #dda458; }
+
+.ct-series-k .ct-point, .ct-series-k .ct-line, .ct-series-k .ct-bar, .ct-series-k .ct-slice-donut {
+ stroke: #eacf7d; }
+
+.ct-series-k .ct-slice-pie, .ct-series-k .ct-area {
+ fill: #eacf7d; }
+
+.ct-series-l .ct-point, .ct-series-l .ct-line, .ct-series-l .ct-bar, .ct-series-l .ct-slice-donut {
+ stroke: #86797d; }
+
+.ct-series-l .ct-slice-pie, .ct-series-l .ct-area {
+ fill: #86797d; }
+
+.ct-series-m .ct-point, .ct-series-m .ct-line, .ct-series-m .ct-bar, .ct-series-m .ct-slice-donut {
+ stroke: #b2c326; }
+
+.ct-series-m .ct-slice-pie, .ct-series-m .ct-area {
+ fill: #b2c326; }
+
+.ct-series-n .ct-point, .ct-series-n .ct-line, .ct-series-n .ct-bar, .ct-series-n .ct-slice-donut {
+ stroke: #6188e2; }
+
+.ct-series-n .ct-slice-pie, .ct-series-n .ct-area {
+ fill: #6188e2; }
+
+.ct-series-o .ct-point, .ct-series-o .ct-line, .ct-series-o .ct-bar, .ct-series-o .ct-slice-donut {
+ stroke: #a748ca; }
+
+.ct-series-o .ct-slice-pie, .ct-series-o .ct-area {
+ fill: #a748ca; }
+
+.ct-square {
+ display: block;
+ position: relative;
+ width: 100%; }
+ .ct-square:before {
+ display: block;
+ float: left;
+ content: "";
+ width: 0;
+ height: 0;
+ padding-bottom: 100%; }
+ .ct-square:after {
+ content: "";
+ display: table;
+ clear: both; }
+ .ct-square > svg {
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0; }
+
+.ct-minor-second {
+ display: block;
+ position: relative;
+ width: 100%; }
+ .ct-minor-second:before {
+ display: block;
+ float: left;
+ content: "";
+ width: 0;
+ height: 0;
+ padding-bottom: 93.75%; }
+ .ct-minor-second:after {
+ content: "";
+ display: table;
+ clear: both; }
+ .ct-minor-second > svg {
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0; }
+
+.ct-major-second {
+ display: block;
+ position: relative;
+ width: 100%; }
+ .ct-major-second:before {
+ display: block;
+ float: left;
+ content: "";
+ width: 0;
+ height: 0;
+ padding-bottom: 88.8888888889%; }
+ .ct-major-second:after {
+ content: "";
+ display: table;
+ clear: both; }
+ .ct-major-second > svg {
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0; }
+
+.ct-minor-third {
+ display: block;
+ position: relative;
+ width: 100%; }
+ .ct-minor-third:before {
+ display: block;
+ float: left;
+ content: "";
+ width: 0;
+ height: 0;
+ padding-bottom: 83.3333333333%; }
+ .ct-minor-third:after {
+ content: "";
+ display: table;
+ clear: both; }
+ .ct-minor-third > svg {
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0; }
+
+.ct-major-third {
+ display: block;
+ position: relative;
+ width: 100%; }
+ .ct-major-third:before {
+ display: block;
+ float: left;
+ content: "";
+ width: 0;
+ height: 0;
+ padding-bottom: 80%; }
+ .ct-major-third:after {
+ content: "";
+ display: table;
+ clear: both; }
+ .ct-major-third > svg {
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0; }
+
+.ct-perfect-fourth {
+ display: block;
+ position: relative;
+ width: 100%; }
+ .ct-perfect-fourth:before {
+ display: block;
+ float: left;
+ content: "";
+ width: 0;
+ height: 0;
+ padding-bottom: 75%; }
+ .ct-perfect-fourth:after {
+ content: "";
+ display: table;
+ clear: both; }
+ .ct-perfect-fourth > svg {
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0; }
+
+.ct-perfect-fifth {
+ display: block;
+ position: relative;
+ width: 100%; }
+ .ct-perfect-fifth:before {
+ display: block;
+ float: left;
+ content: "";
+ width: 0;
+ height: 0;
+ padding-bottom: 66.6666666667%; }
+ .ct-perfect-fifth:after {
+ content: "";
+ display: table;
+ clear: both; }
+ .ct-perfect-fifth > svg {
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0; }
+
+.ct-minor-sixth {
+ display: block;
+ position: relative;
+ width: 100%; }
+ .ct-minor-sixth:before {
+ display: block;
+ float: left;
+ content: "";
+ width: 0;
+ height: 0;
+ padding-bottom: 62.5%; }
+ .ct-minor-sixth:after {
+ content: "";
+ display: table;
+ clear: both; }
+ .ct-minor-sixth > svg {
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0; }
+
+.ct-golden-section {
+ display: block;
+ position: relative;
+ width: 100%; }
+ .ct-golden-section:before {
+ display: block;
+ float: left;
+ content: "";
+ width: 0;
+ height: 0;
+ padding-bottom: 61.804697157%; }
+ .ct-golden-section:after {
+ content: "";
+ display: table;
+ clear: both; }
+ .ct-golden-section > svg {
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0; }
+
+.ct-major-sixth {
+ display: block;
+ position: relative;
+ width: 100%; }
+ .ct-major-sixth:before {
+ display: block;
+ float: left;
+ content: "";
+ width: 0;
+ height: 0;
+ padding-bottom: 60%; }
+ .ct-major-sixth:after {
+ content: "";
+ display: table;
+ clear: both; }
+ .ct-major-sixth > svg {
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0; }
+
+.ct-minor-seventh {
+ display: block;
+ position: relative;
+ width: 100%; }
+ .ct-minor-seventh:before {
+ display: block;
+ float: left;
+ content: "";
+ width: 0;
+ height: 0;
+ padding-bottom: 56.25%; }
+ .ct-minor-seventh:after {
+ content: "";
+ display: table;
+ clear: both; }
+ .ct-minor-seventh > svg {
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0; }
+
+.ct-major-seventh {
+ display: block;
+ position: relative;
+ width: 100%; }
+ .ct-major-seventh:before {
+ display: block;
+ float: left;
+ content: "";
+ width: 0;
+ height: 0;
+ padding-bottom: 53.3333333333%; }
+ .ct-major-seventh:after {
+ content: "";
+ display: table;
+ clear: both; }
+ .ct-major-seventh > svg {
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0; }
+
+.ct-octave {
+ display: block;
+ position: relative;
+ width: 100%; }
+ .ct-octave:before {
+ display: block;
+ float: left;
+ content: "";
+ width: 0;
+ height: 0;
+ padding-bottom: 50%; }
+ .ct-octave:after {
+ content: "";
+ display: table;
+ clear: both; }
+ .ct-octave > svg {
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0; }
+
+.ct-major-tenth {
+ display: block;
+ position: relative;
+ width: 100%; }
+ .ct-major-tenth:before {
+ display: block;
+ float: left;
+ content: "";
+ width: 0;
+ height: 0;
+ padding-bottom: 40%; }
+ .ct-major-tenth:after {
+ content: "";
+ display: table;
+ clear: both; }
+ .ct-major-tenth > svg {
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0; }
+
+.ct-major-eleventh {
+ display: block;
+ position: relative;
+ width: 100%; }
+ .ct-major-eleventh:before {
+ display: block;
+ float: left;
+ content: "";
+ width: 0;
+ height: 0;
+ padding-bottom: 37.5%; }
+ .ct-major-eleventh:after {
+ content: "";
+ display: table;
+ clear: both; }
+ .ct-major-eleventh > svg {
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0; }
+
+.ct-major-twelfth {
+ display: block;
+ position: relative;
+ width: 100%; }
+ .ct-major-twelfth:before {
+ display: block;
+ float: left;
+ content: "";
+ width: 0;
+ height: 0;
+ padding-bottom: 33.3333333333%; }
+ .ct-major-twelfth:after {
+ content: "";
+ display: table;
+ clear: both; }
+ .ct-major-twelfth > svg {
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0; }
+
+.ct-double-octave {
+ display: block;
+ position: relative;
+ width: 100%; }
+ .ct-double-octave:before {
+ display: block;
+ float: left;
+ content: "";
+ width: 0;
+ height: 0;
+ padding-bottom: 25%; }
+ .ct-double-octave:after {
+ content: "";
+ display: table;
+ clear: both; }
+ .ct-double-octave > svg {
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0; }
+
+/*# sourceMappingURL=chartist.css.map */
diff --git a/app/controllers/address_endpoints_controller.rb b/app/controllers/address_endpoints_controller.rb
new file mode 100644
index 0000000..367ca0b
--- /dev/null
+++ b/app/controllers/address_endpoints_controller.rb
@@ -0,0 +1,45 @@
+class AddressEndpointsController < ApplicationController
+
+ include WithinOrganization
+
+ before_action { @server = organization.servers.present.find_by_permalink!(params[:server_id]) }
+ before_action { params[:id] && @address_endpoint = @server.address_endpoints.find_by_uuid!(params[:id]) }
+
+ def index
+ @address_endpoints = @server.address_endpoints.order(:address).to_a
+ end
+
+ def new
+ @address_endpoint = @server.address_endpoints.build
+ end
+
+ def create
+ @address_endpoint = @server.address_endpoints.build(safe_params)
+ if @address_endpoint.save
+ flash[:notice] = params[:return_notice] if params[:return_notice].present?
+ redirect_to_with_json [:return_to, [organization, @server, :address_endpoints]]
+ else
+ render_form_errors 'new', @address_endpoint
+ end
+ end
+
+ def update
+ if @address_endpoint.update(safe_params)
+ redirect_to_with_json [organization, @server, :address_endpoints]
+ else
+ render_form_errors 'edit', @address_endpoint
+ end
+ end
+
+ def destroy
+ @address_endpoint.destroy
+ redirect_to_with_json [organization, @server, :address_endpoints]
+ end
+
+ private
+
+ def safe_params
+ params.require(:address_endpoint).permit(:address)
+ end
+
+end
diff --git a/app/controllers/admin/organizations_controller.rb b/app/controllers/admin/organizations_controller.rb
new file mode 100644
index 0000000..88e549a
--- /dev/null
+++ b/app/controllers/admin/organizations_controller.rb
@@ -0,0 +1,10 @@
+class Admin::OrganizationsController < ApplicationController
+
+ before_action :admin_required
+ before_action { params[:id] && @organization = Organization.find_by_permalink!(params[:id]) }
+
+ def index
+ @organizations = Organization.order(:created_at => :desc).includes(:owner).page(params[:page])
+ end
+
+end
diff --git a/app/controllers/admin/stats_controller.rb b/app/controllers/admin/stats_controller.rb
new file mode 100644
index 0000000..cdcb6b0
--- /dev/null
+++ b/app/controllers/admin/stats_controller.rb
@@ -0,0 +1,10 @@
+class Admin::StatsController < ApplicationController
+
+ before_action :admin_required
+
+ def stats
+ @stats = Statistic.global
+ @queue_size = QueuedMessage.unlocked.retriable.count
+ end
+
+end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
new file mode 100644
index 0000000..410f698
--- /dev/null
+++ b/app/controllers/application_controller.rb
@@ -0,0 +1,131 @@
+require 'authie/session'
+
+class ApplicationController < ActionController::Base
+
+ protect_from_forgery with: :exception
+
+ before_action :login_required
+ before_action :verified_email_required
+ before_action :set_timezone
+
+ rescue_from Authie::Session::InactiveSession, :with => :auth_session_error
+ rescue_from Authie::Session::ExpiredSession, :with => :auth_session_error
+ rescue_from Authie::Session::BrowserMismatch, :with => :auth_session_error
+
+ private
+
+ def login_required
+ unless logged_in?
+ redirect_to login_path(:return_to => request.fullpath)
+ end
+ end
+
+ def admin_required
+ if logged_in?
+ unless current_user.admin?
+ render :text => "Not permitted"
+ end
+ else
+ redirect_to login_path(:return_to => request.fullpath)
+ end
+ end
+
+ def verified_email_required
+ if logged_in? && !current_user.verified?
+ redirect_to verify_path(:return_to => request.fullpath)
+ end
+ end
+
+ def require_organization_admin
+ unless organization.admin?(current_user)
+ redirect_to organization_root_path(organization), :alert => "This page can only be accessed by the organization admins"
+ end
+ end
+
+ def require_organization_owner
+ unless organization.owner == current_user
+ redirect_to organization_root_path(organization), :alert => "This page can only be accessed by the organization's owner (#{organization.owner.name})"
+ end
+ end
+
+ def auth_session_error(exception)
+ Rails.logger.info "AuthSessionError: #{exception.class}: #{exception.message}"
+ redirect_to login_path(:return_to => request.fullpath)
+ end
+
+ def page_title
+ @page_title ||= ["Postal"]
+ end
+ helper_method :page_title
+
+ def redirect_to_with_return_to(url, *args)
+ if params[:return_to].blank? || !params[:return_to].starts_with?('/')
+ redirect_to url_with_return_to(url), *args
+ else
+ redirect_to url_with_return_to(url), *args
+ end
+ end
+
+ def set_timezone
+ Time.zone = logged_in? ? current_user.time_zone : 'UTC'
+ end
+
+ def append_info_to_payload(payload)
+ super
+ payload[:ip] = request.ip
+ payload[:user] = logged_in? ? current_user.id : nil
+ end
+
+ def url_with_return_to(url)
+ if params[:return_to].blank? || !params[:return_to].starts_with?('/')
+ url_for(url)
+ else
+ params[:return_to]
+ end
+ end
+
+ def redirect_to_with_json(url, flash_messages = {})
+ if url.is_a?(Array) && url[0] == :return_to
+ url = url_with_return_to(url[1])
+ else
+ url = url_for(url)
+ end
+
+ flash_messages.each do |key, value|
+ flash[key] = value
+ end
+ respond_to do |wants|
+ wants.html { redirect_to url }
+ wants.json { render :json => {:redirect_to => url} }
+ end
+ end
+
+ def render_form_errors(action_name, object)
+ respond_to do |wants|
+ wants.html { render action_name }
+ wants.json { render :json => {:form_errors => object.errors.full_messages}, :status => 422 }
+ end
+ end
+
+ def flash_now(type, message, options = {})
+ respond_to do |wants|
+ wants.html do
+ flash.now[type] = message
+ if options[:render_action]
+ render options[:render_action]
+ end
+ end
+ wants.json { render :json => {:flash => {type => message}} }
+ end
+ end
+
+ def login(user)
+ if logged_in?
+ auth_session.invalidate!
+ reset_session
+ end
+ Authie::Session.start(self, :user => user)
+ @current_user = user
+ end
+
+end
diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/app/controllers/concerns/within_organization.rb b/app/controllers/concerns/within_organization.rb
new file mode 100644
index 0000000..616d1c9
--- /dev/null
+++ b/app/controllers/concerns/within_organization.rb
@@ -0,0 +1,20 @@
+module WithinOrganization
+
+ extend ActiveSupport::Concern
+
+ included do
+ helper_method :organization
+ before_action :add_organization_to_page_title
+ end
+
+ private
+
+ def organization
+ @organization ||= current_user.organizations_scope.find_by_permalink!(params[:org_permalink])
+ end
+
+ def add_organization_to_page_title
+ page_title << organization.name
+ end
+
+end
diff --git a/app/controllers/credentials_controller.rb b/app/controllers/credentials_controller.rb
new file mode 100644
index 0000000..c44336a
--- /dev/null
+++ b/app/controllers/credentials_controller.rb
@@ -0,0 +1,38 @@
+class CredentialsController < ApplicationController
+
+ include WithinOrganization
+
+ before_action { @server = organization.servers.present.find_by_permalink!(params[:server_id]) }
+ before_action { params[:id] && @credential = @server.credentials.find_by_key!(params[:id]) }
+
+ def index
+ @credentials = @server.credentials.order(:name).to_a
+ end
+
+ def new
+ @credential = @server.credentials.build
+ end
+
+ def create
+ @credential = @server.credentials.build(params.require(:credential).permit(:type, :name, :hold))
+ if @credential.save
+ redirect_to_with_json [organization, @server, :credentials]
+ else
+ render_form_errors 'new', @credential
+ end
+ end
+
+ def update
+ if @credential.update(params.require(:credential).permit(:name, :hold))
+ redirect_to_with_json [organization, @server, :credentials]
+ else
+ render_form_errors 'edit', @credential
+ end
+ end
+
+ def destroy
+ @credential.destroy
+ redirect_to_with_json [organization, @server, :credentials]
+ end
+
+end
diff --git a/app/controllers/domains_controller.rb b/app/controllers/domains_controller.rb
new file mode 100644
index 0000000..46a0f16
--- /dev/null
+++ b/app/controllers/domains_controller.rb
@@ -0,0 +1,102 @@
+class DomainsController < ApplicationController
+
+ include WithinOrganization
+
+ before_action do
+ if params[:server_id]
+ @server = organization.servers.present.find_by_permalink!(params[:server_id])
+ params[:id] && @domain = @server.domains.find_by_uuid!(params[:id])
+ else
+ params[:id] && @domain = organization.domains.find_by_uuid!(params[:id])
+ end
+ end
+
+ def index
+ if @server
+ @domains = @server.domains.order(:name).to_a
+ else
+ @domains = organization.domains.order(:name).to_a
+ end
+ end
+
+ def new
+ @domain = @server ? @server.domains.build : organization.domains.build
+ end
+
+ def create
+ scope = @server ? @server.domains : organization.domains
+ @domain = scope.build(params.require(:domain).permit(:name, :verification_method))
+
+ if @domain.save
+ if @auto_verified
+ flash[:notice] = "Ownership of this domain does not need to be verified because it already has been verified in this organization."
+ redirect_to_with_json [:setup, organization, @server, @domain]
+ else
+ redirect_to_with_json [:verify, organization, @server, @domain]
+ end
+ else
+ render_form_errors 'new', @domain
+ end
+ end
+
+ def destroy
+ @domain.destroy
+ redirect_to_with_json [organization, @server, :domains]
+ end
+
+ def verify
+ if @domain.verified?
+ redirect_to [organization, @server, :domains], :alert => "#{@domain.name} has already been verified."
+ return
+ end
+
+ if request.post?
+ case @domain.verification_method
+ when 'DNS'
+ if @domain.verify_with_dns
+ redirect_to_with_json [:setup, organization, @server, @domain], :notice => "#{@domain.name} has been verified successfully. You now need to configure your DNS records."
+ else
+ respond_to do |wants|
+ wants.html { flash.now[:alert] = "We couldn't verify your domain. Please double check you've added the TXT record correctly." }
+ wants.json { render :json => {:flash => {:alert => "We couldn't verify your domain. Please double check you've added the TXT record correctly."}}}
+ end
+ end
+ when 'Email'
+ if params[:code]
+ if @domain.verification_token == params[:code].to_s.strip
+ @domain.verify
+ redirect_to_with_json [:setup, organization, @server, @domain], :notice => "#{@domain.name} has been verified successfully. You now need to configure your DNS records."
+ else
+ respond_to do |wants|
+ wants.html { flash.now[:alert] = "Invalid verification code. Please check and try again." }
+ wants.json { render :json => {:flash => {:alert => "Invalid verification code. Please check and try again."}}}
+ end
+ end
+ elsif params[:email_address].present?
+ raise Postal::Error, "Invalid email address" unless @domain.verification_email_addresses.include?(params[:email_address])
+ AppMailer.verify_domain(@domain, params[:email_address], current_user).deliver
+ if @domain.owner.is_a?(Server)
+ redirect_to_with_json verify_organization_server_domain_path(organization, @server, @domain, :email_address => params[:email_address])
+ else
+ redirect_to_with_json verify_organization_domain_path(organization, @domain, :email_address => params[:email_address])
+ end
+ end
+ end
+ end
+ end
+
+ def setup
+ unless @domain.verified?
+ redirect_to [:verify, organization, @server, @domain], :alert => "You can't set up DNS for this domain until it has been verified."
+ end
+ end
+
+ def check
+ if @domain.check_dns(:manual)
+ redirect_to_with_json [organization, @server, :domains], :notice => "Your DNS records for #{@domain.name} look good!"
+ else
+ redirect_to_with_json [:setup, organization, @server, @domain], :alert => "There seems to be something wrong with your DNS records. Check below for information."
+ end
+ end
+
+end
diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb
new file mode 100644
index 0000000..51d8bf0
--- /dev/null
+++ b/app/controllers/help_controller.rb
@@ -0,0 +1,11 @@
+class HelpController < ApplicationController
+
+ include WithinOrganization
+
+ before_action { @server = organization.servers.find_by_permalink!(params[:server_id]) }
+
+ def outgoing
+ @credentials = @server.credentials.group_by(&:type)
+ end
+
+end
diff --git a/app/controllers/http_endpoints_controller.rb b/app/controllers/http_endpoints_controller.rb
new file mode 100644
index 0000000..bfd3831
--- /dev/null
+++ b/app/controllers/http_endpoints_controller.rb
@@ -0,0 +1,45 @@
+class HTTPEndpointsController < ApplicationController
+
+ include WithinOrganization
+
+ before_action { @server = organization.servers.present.find_by_permalink!(params[:server_id]) }
+ before_action { params[:id] && @http_endpoint = @server.http_endpoints.find_by_uuid!(params[:id]) }
+
+ def index
+ @http_endpoints = @server.http_endpoints.order(:name).to_a
+ end
+
+ def new
+ @http_endpoint = @server.http_endpoints.build
+ end
+
+ def create
+ @http_endpoint = @server.http_endpoints.build(safe_params)
+ if @http_endpoint.save
+ flash[:notice] = params[:return_notice] if params[:return_notice].present?
+ redirect_to_with_json [:return_to, [organization, @server, :http_endpoints]]
+ else
+ render_form_errors 'new', @http_endpoint
+ end
+ end
+
+ def update
+ if @http_endpoint.update(safe_params)
+ redirect_to_with_json [organization, @server, :http_endpoints]
+ else
+ render_form_errors 'edit', @http_endpoint
+ end
+ end
+
+ def destroy
+ @http_endpoint.destroy
+ redirect_to_with_json [organization, @server, :http_endpoints]
+ end
+
+ private
+
+ def safe_params
+ params.require(:http_endpoint).permit(:name, :url, :encoding, :format, :strip_replies, :include_attachments, :timeout)
+ end
+
+end
diff --git a/app/controllers/ip_pool_rules_controller.rb b/app/controllers/ip_pool_rules_controller.rb
new file mode 100644
index 0000000..75c8b27
--- /dev/null
+++ b/app/controllers/ip_pool_rules_controller.rb
@@ -0,0 +1,55 @@
+class IPPoolRulesController < ApplicationController
+
+ include WithinOrganization
+
+ before_action do
+ if params[:server_id]
+ @server = organization.servers.present.find_by_permalink!(params[:server_id])
+ params[:id] && @ip_pool_rule = @server.ip_pool_rules.find_by_uuid!(params[:id])
+ else
+ params[:id] && @ip_pool_rule = organization.ip_pool_rules.find_by_uuid!(params[:id])
+ end
+ end
+
+ def index
+ if @server
+ @ip_pool_rules = @server.ip_pool_rules
+ else
+ @ip_pool_rules = organization.ip_pool_rules
+ end
+ end
+
+ def new
+ @ip_pool_rule = @server ? @server.ip_pool_rules.build : organization.ip_pool_rules.build
+ end
+
+ def create
+ scope = @server ? @server.ip_pool_rules : organization.ip_pool_rules
+ @ip_pool_rule = scope.build(safe_params)
+ if @ip_pool_rule.save
+ redirect_to_with_json [organization, @server, :ip_pool_rules]
+ else
+ render_form_errors 'new', @ip_pool_rule
+ end
+ end
+
+ def update
+ if @ip_pool_rule.update(safe_params)
+ redirect_to_with_json [organization, @server, :ip_pool_rules]
+ else
+ render_form_errors 'edit', @ip_pool_rule
+ end
+ end
+
+ def destroy
+ @ip_pool_rule.destroy
+ redirect_to_with_json [organization, @server, :ip_pool_rules]
+ end
+
+ private
+
+ def safe_params
+ params.require(:ip_pool_rule).permit(:from_text, :to_text, :ip_pool_id)
+ end
+
+end
diff --git a/app/controllers/ip_pools_controller.rb b/app/controllers/ip_pools_controller.rb
new file mode 100644
index 0000000..ec4662c
--- /dev/null
+++ b/app/controllers/ip_pools_controller.rb
@@ -0,0 +1,9 @@
+class IPPoolsController < ApplicationController
+
+ include WithinOrganization
+
+ def index
+ @ip_pools = organization.ip_pools.dedicated.order(:name)
+ end
+
+end
diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb
new file mode 100644
index 0000000..db3f245
--- /dev/null
+++ b/app/controllers/messages_controller.rb
@@ -0,0 +1,226 @@
+ class MessagesController < ApplicationController
+
+ include WithinOrganization
+
+ before_action { @server = organization.servers.present.find_by_permalink!(params[:server_id]) }
+ before_action { params[:id] && @message = @server.message_db.message(params[:id].to_i) }
+
+ def new
+ if params[:direction] == 'incoming'
+ @message = IncomingMessagePrototype.new(@server, request.ip, 'web-ui', {})
+ @message.from = session[:test_in_from] || current_user.email_tag
+ @message.to = @server.routes.order(:name).first&.description
+ else
+ @message = OutgoingMessagePrototype.new(@server, request.ip, 'web-ui', {})
+ @message.to = session[:test_out_to] || current_user.email_address
+ if domain = @server.domains.verified.order(:name).first
+ @message.from = "test@#{domain.name}"
+ end
+ end
+ @message.subject = "Test Message at #{Time.now.to_s(:long)}"
+ @message.plain_body = "This is a message to test the delivery of messages through Postal."
+ end
+
+ def create
+ if params[:direction] == 'incoming'
+ session[:test_in_from] = params[:message][:from] if params[:message]
+ @message = IncomingMessagePrototype.new(@server, request.ip, 'web-ui', params[:message])
+ @message.attachments = [{:name => "test.txt", :content_type => "text/plain", :data => "Hello world!"}]
+ else
+ session[:test_out_to] = params[:message][:to] if params[:message]
+ @message = OutgoingMessagePrototype.new(@server, request.ip, 'web-ui', params[:message])
+ end
+ if result = @message.create_messages
+ if result.size == 1
+ redirect_to_with_json organization_server_message_path(organization, @server, result.first.last[:id]), :notice => "Message was queued successfully"
+ else
+ redirect_to_with_json [:queue, organization, @server], :notice => "Messages queued successfully "
+ end
+ else
+ respond_to do |wants|
+ wants.html do
+ flash.now[:alert] = "Your message could not be sent. Ensure that all fields are completed fully. #{result.errors.inspect}"
+ render 'new'
+ end
+ wants.json do
+ render :json => {:flash => {:alert => "Your message could not be sent. Please check all field are completed fully."}}
+ end
+ end
+
+ end
+ end
+
+ def outgoing
+ @searchable = true
+ get_messages('outgoing')
+ respond_to do |wants|
+ wants.html
+ wants.json { render :json => {
+ :flash => flash.each_with_object({}) { |(type, message), hash| hash[type] = message},
+ :region_html => render_to_string(:partial => 'index', :formats => [:html])
+ }}
+ end
+ end
+
+ def incoming
+ @searchable = true
+ get_messages('incoming')
+ respond_to do |wants|
+ wants.html
+ wants.json { render :json => {
+ :flash => flash.each_with_object({}) { |(type, message), hash| hash[type] = message},
+ :region_html => render_to_string(:partial => 'index', :formats => [:html])
+ }}
+ end
+ end
+
+ def held
+ get_messages('held')
+ end
+
+ def deliveries
+ render :json => {:html => render_to_string(:partial => 'deliveries', :locals => {:message => @message})}
+ end
+
+ def html_raw
+ render :html => @message.html_body_without_tracking_image.html_safe
+ end
+
+ def spam_checks
+ @spam_checks = @message.spam_checks.sort_by { |s| s['score']}.reverse
+ end
+
+ def attachment
+ if @message.attachments.size > params[:attachment].to_i
+ attachment = @message.attachments[params[:attachment].to_i]
+ send_data attachment.body, :content_type => attachment.mime_type, :disposition => 'download', :filename => attachment.filename
+ else
+ redirect_to attachments_organization_server_message_path(organization, @server, @message.id), :alert => "Attachment not found. Choose an attachment from the list below."
+ end
+ end
+
+ def download
+ if @message.raw_message
+ send_data @message.raw_message, :filename => "Message-#{organization.permalink}-#{@server.permalink}-#{@message.id}.eml", :content_type => "text/plain"
+ else
+ redirect_to organization_server_message_path(organization, @server, @message.id), :alert => "We no longer have the raw message stored for this message."
+ end
+ end
+
+ def retry
+ if @message.queued_message
+ @message.queued_message.queue!
+ flash[:notice] = "This message will be retried shortly."
+ elsif @message.held?
+ @message.add_to_message_queue(:manual => true)
+ flash[:notice] = "This message has been released. Delivery will be attempted shortly."
+ elsif @server.mode == 'Development'
+ @message.add_to_message_queue(:manual => true)
+ flash[:notice] = "This message will be redelivered shortly."
+ else
+ flash[:alert] = "This message is no longer queued for sending."
+ end
+ redirect_to_with_json organization_server_message_path(organization, @server, @message.id)
+ end
+
+ def cancel_hold
+ @message.cancel_hold
+ redirect_to_with_json organization_server_message_path(organization, @server, @message.id)
+ end
+
+ def remove_from_queue
+ if @message.queued_message && !@message.queued_message.locked?
+ @message.queued_message.destroy
+ end
+ redirect_to_with_json organization_server_message_path(organization, @server, @message.id)
+ end
+
+ def suppressions
+ @suppressions = @server.message_db.suppression_list.all_with_pagination(params[:page])
+ end
+
+ def activity
+ @entries = @message.activity_entries
+ end
+
+ private
+
+ def get_messages(scope)
+ if scope == 'held'
+ options = {:where => {:held => 1}}
+ else
+ options = {:where => {:scope => scope, :spam => false}, :order => :timestamp, :direction => 'desc'}
+
+ if @query = (params[:query] || session["msg_query_#{@server.id}_#{scope}"]).presence
+ session["msg_query_#{@server.id}_#{scope}"] = @query
+ qs = Postal::QueryString.new(@query)
+ if qs.empty?
+ flash.now[:alert] = "It doesn't appear you entered anything to filter on. Please double check your query."
+ else
+ @queried = true
+ if qs[:order] == 'oldest-first'
+ options[:direction] = 'asc'
+ end
+
+ options[:where][:rcpt_to] = qs[:to] if qs[:to]
+ options[:where][:mail_from] = qs[:from] if qs[:from]
+ options[:where][:status] = qs[:status] if qs[:status]
+ options[:where][:token] = qs[:token] if qs[:token]
+
+ if qs[:msgid]
+ options[:where][:message_id] = qs[:msgid]
+ options[:where].delete(:spam)
+ options[:where].delete(:scope)
+ end
+ options[:where][:tag] = qs[:tag] if qs[:tag]
+ options[:where][:id] = qs[:id] if qs[:id]
+ options[:where][:spam] = true if qs[:spam] == 'yes' || qs[:spam] == 'y'
+ if qs[:before] || qs[:after]
+ options[:where][:timestamp] = {}
+ if qs[:before]
+ begin
+ options[:where][:timestamp][:less_than] = get_time_from_string(qs[:before]).to_f
+ rescue TimeUndetermined => e
+ flash.now[:alert] = "Couldn't determine time for before from '#{qs[:before]}'"
+ end
+ end
+
+ if qs[:after]
+ begin
+ options[:where][:timestamp][:greater_than] = get_time_from_string(qs[:after]).to_f
+ rescue TimeUndetermined => e
+ flash.now[:alert] = "Couldn't determine time for after from '#{qs[:after]}'"
+ end
+ end
+ end
+ end
+ else
+ session["msg_query_#{@server.id}_#{scope}"] = nil
+ end
+ end
+
+ @messages = @server.message_db.messages_with_pagination(params[:page], options)
+ end
+
+ class TimeUndetermined < Postal::Error; end
+
+ def get_time_from_string(string)
+ begin
+ if string =~ /\A(\d{2,4})\-(\d{2})\-(\d{2}) (\d{2})\:(\d{2})\z/
+ time = Time.new($1.to_i, $2.to_i, $3.to_i, $4.to_i, $5.to_i)
+ elsif string =~ /\A(\d{2,4})\-(\d{2})\-(\d{2})\z/
+ time = Time.new($1.to_i, $2.to_i, $3.to_i, 0)
+ else
+ time = Chronic.parse(string, :context => :past)
+ end
+ rescue
+ end
+
+ if time.nil?
+ raise TimeUndetermined, "Couldn't determine a suitable time from '#{string}'"
+ else
+ time
+ end
+ end
+
+end
diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb
new file mode 100644
index 0000000..1e12b5c
--- /dev/null
+++ b/app/controllers/organizations_controller.rb
@@ -0,0 +1,62 @@
+class OrganizationsController < ApplicationController
+
+ before_action :require_organization_admin, :only => [:edit, :update, :delete, :destroy]
+
+ def index
+ @organizations = current_user.organizations.present.order(:name).to_a
+ if @organizations.size == 1 && params[:nrd].nil?
+ redirect_to organization_root_path(@organizations.first)
+ end
+ end
+
+ def new
+ @organization = Organization.new
+ end
+
+ def create
+ @organization = Organization.new(params.require(:organization).permit(:name, :permalink))
+ @organization.owner = current_user
+ if @organization.save
+ @organization.users << current_user
+ redirect_to_with_json organization_root_path(@organization)
+ else
+ render_form_errors 'new', @organization
+ end
+ end
+
+ def edit
+ @organization_obj = current_user.organizations_scope.find(organization.id)
+ end
+
+ def update
+ @organization_obj = current_user.organizations_scope.find(organization.id)
+ if @organization_obj.update(params.require(:organization).permit(:name, :time_zone))
+ redirect_to_with_json organization_settings_path(@organization_obj), :notice => "Settings for #{@organization_obj.name} have been saved successfully."
+ else
+ render_form_errors 'edit', @organization_obj
+ end
+ end
+
+ def destroy
+ unless current_user.authenticate(params[:password])
+ respond_to do |wants|
+ wants.html { redirect_to organization_delete_path(@organization), :alert => "The password you entered was not valid. Please check and try again." }
+ wants.json { render :json => {:alert => "The password you entered was invalid. Please check and try again."} }
+ end
+ return
+ end
+
+ organization.soft_destroy
+ redirect_to_with_json root_path(:nrd => 1), :notice => "#{@organization.name} has been removed successfully."
+ end
+
+ private
+
+ def organization
+ if [:edit, :update, :delete, :destroy].include?(action_name.to_sym)
+ @organization ||= params[:org_permalink] ? current_user.organizations_scope.find_by_permalink!(params[:org_permalink]) : nil
+ end
+ end
+ helper_method :organization
+
+end
diff --git a/app/controllers/routes_controller.rb b/app/controllers/routes_controller.rb
new file mode 100644
index 0000000..ea3d03d
--- /dev/null
+++ b/app/controllers/routes_controller.rb
@@ -0,0 +1,44 @@
+class RoutesController < ApplicationController
+
+ include WithinOrganization
+
+ before_action { @server = organization.servers.present.find_by_permalink!(params[:server_id]) }
+ before_action { params[:id] && @route = @server.routes.find_by_uuid!(params[:id]) }
+
+ def index
+ @routes = @server.routes.order(:name).includes(:domain, :endpoint).to_a
+ end
+
+ def new
+ @route = @server.routes.build
+ end
+
+ def create
+ @route = @server.routes.build(safe_params)
+ if @route.save
+ redirect_to_with_json [organization, @server, :routes]
+ else
+ render_form_errors 'new', @route
+ end
+ end
+
+ def update
+ if @route.update(safe_params)
+ redirect_to_with_json [organization, @server, :routes]
+ else
+ render_form_errors 'edit', @route
+ end
+ end
+
+ def destroy
+ @route.destroy
+ redirect_to_with_json [organization, @server, :routes]
+ end
+
+ private
+
+ def safe_params
+ params.require(:route).permit(:name, :domain_id, :spam_mode, :_endpoint, :additional_route_endpoints_array => [])
+ end
+
+end
diff --git a/app/controllers/servers_controller.rb b/app/controllers/servers_controller.rb
new file mode 100644
index 0000000..28ee819
--- /dev/null
+++ b/app/controllers/servers_controller.rb
@@ -0,0 +1,90 @@
+class ServersController < ApplicationController
+
+ include WithinOrganization
+
+ before_action :require_organization_admin, :only => [:new, :create, :delete, :destroy]
+ before_action :admin_required, :only => [:admin, :suspend, :unsuspend]
+ before_action { params[:id] && @server = organization.servers.present.find_by_permalink!(params[:id]) }
+
+ def index
+ @servers = organization.servers.present.order(:name).to_a
+ end
+
+ def show
+ if @server.created_at < 48.hours.ago
+ @graph_type = :daily
+ graph_data = @server.message_db.statistics.get(:daily, [:incoming, :outgoing, :bounces], Time.now, 30)
+ elsif @server.created_at < 24.hours.ago
+ @graph_type = :hourly
+ graph_data = @server.message_db.statistics.get(:hourly, [:incoming, :outgoing, :bounces], Time.now, 48)
+ else
+ @graph_type = :hourly
+ graph_data = @server.message_db.statistics.get(:hourly, [:incoming, :outgoing, :bounces], Time.now, 24)
+ end
+ @first_date = graph_data.first.first
+ @last_date = graph_data.last.first
+ @graph_data = graph_data.map(&:last)
+ @messages = @server.message_db.messages(:order => 'id', :direction => 'desc', :limit => 6)
+ end
+
+ def new
+ @server = organization.servers.build
+ end
+
+ def create
+ @server = organization.servers.build(safe_params(:permalink))
+ if @server.save
+ redirect_to_with_json organization_server_path(organization, @server)
+ else
+ render_form_errors 'new', @server
+ end
+ end
+
+ def update
+ extra_params = [:spam_threshold, :spam_failure_threshold, :postmaster_address]
+ extra_params += [:send_limit, :allow_sender, :log_smtp_data, :outbound_spam_threshold, :message_retention_days, :raw_message_retention_days, :raw_message_retention_size, :ip_pool_id] if current_user.admin?
+ if @server.update(safe_params(*extra_params))
+ redirect_to_with_json organization_server_path(organization, @server), :notice => "Server settings have been updated"
+ else
+ render_form_errors 'edit', @server
+ end
+ end
+
+ def destroy
+ unless current_user.authenticate(params[:password])
+ respond_to do |wants|
+ wants.html do
+ redirect_to [:delete, organization, @server], :alert => "The password you entered was not valid. Please check and try again."
+ end
+ wants.json do
+ render :json => {:alert => "The password you entere was invalid. Please check and try again"}
+ end
+ end
+ return
+ end
+ @server.soft_destroy
+ redirect_to_with_json organization_root_path(organization), :notice => "#{@server.name} has been deleted successfully"
+ end
+
+ def queue
+ @messages = @server.queued_messages.order(:id => :desc).page(params[:page])
+ @messages_with_message = @messages.include_message
+ end
+
+ def suspend
+ @server.suspend(params[:reason])
+ redirect_to_with_json [organization, @server], :notice => "Server has been suspended"
+ end
+
+ def unsuspend
+ @server.unsuspend
+ redirect_to_with_json [organization, @server], :notice => "Server has been unsuspended"
+ end
+
+ private
+
+ def safe_params(*extras)
+ params.require(:server).permit(:name, :mode, :ip_pool_id, *extras)
+ end
+
+end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
new file mode 100644
index 0000000..c40602f
--- /dev/null
+++ b/app/controllers/sessions_controller.rb
@@ -0,0 +1,75 @@
+class SessionsController < ApplicationController
+
+ layout 'sub'
+
+ skip_before_action :login_required, :only => [:new, :create, :create_with_token, :begin_password_reset, :finish_password_reset, :ip, :raise_error]
+ skip_before_action :verified_email_required
+
+ def create
+ login(User.authenticate(params[:email_address], params[:password]))
+ flash[:remember_login] = true
+ redirect_to_with_return_to root_path
+ rescue Postal::Errors::AuthenticationError => e
+ flash.now[:alert] = "The credentials you've provided are incorrect. Please check and try again."
+ render 'new'
+ end
+
+ def create_with_token
+ result = JWT.decode(params[:token], Postal.signing_key.to_s, 'HS256')[0]
+ if result['timestamp'] > 1.minute.ago.to_f
+ login(User.find(result['user'].to_i))
+ redirect_to root_path
+ else
+ destroy
+ end
+ rescue JWT::VerificationError
+ destroy
+ end
+
+ def destroy
+ auth_session.invalidate! if logged_in?
+ reset_session
+ redirect_to login_path
+ end
+
+ def persist
+ auth_session.persist! if logged_in?
+ render :plain => "OK"
+ end
+
+ def begin_password_reset
+ if request.post?
+ if user = User.where(:email_address => params[:email_address]).first
+ user.begin_password_reset(params[:return_to])
+ redirect_to login_path(:return_to => params[:return_to]), :notice => "Please check your e-mail and click the link in the e-mail we've sent you."
+ else
+ redirect_to login_reset_path(:return_to => params[:return_to]), :alert => "No user exists with that e-mail address. Please check and try again."
+ end
+ end
+ end
+
+ def finish_password_reset
+ @user = User.where(:password_reset_token => params[:token]).where("password_reset_token_valid_until > ?", Time.now).first
+ if @user.nil?
+ redirect_to login_path(:return_to => params[:return_to]), :alert => "This link has expired or never existed. Please choose reset password to try again."
+ end
+
+ if request.post?
+ if params[:password].blank?
+ flash.now[:alert] = "You must enter a new password"
+ return
+ end
+ @user.password = params[:password]
+ @user.password_confirmation = params[:password_confirmation]
+ if @user.save
+ login(@user)
+ redirect_to_with_return_to root_path, :notice => "Your new password has been set and you've been logged in."
+ end
+ end
+ end
+
+ def ip
+ render :plain => "ip: #{request.ip} remote ip: #{request.remote_ip}"
+ end
+
+end
diff --git a/app/controllers/smtp_endpoints_controller.rb b/app/controllers/smtp_endpoints_controller.rb
new file mode 100644
index 0000000..55f2f0b
--- /dev/null
+++ b/app/controllers/smtp_endpoints_controller.rb
@@ -0,0 +1,43 @@
+class SMTPEndpointsController < ApplicationController
+ include WithinOrganization
+ before_action { @server = organization.servers.present.find_by_permalink!(params[:server_id]) }
+ before_action { params[:id] && @smtp_endpoint = @server.smtp_endpoints.find_by_uuid!(params[:id]) }
+
+ def index
+ @smtp_endpoints = @server.smtp_endpoints.order(:name).to_a
+ end
+
+ def new
+ @smtp_endpoint = @server.smtp_endpoints.build
+ end
+
+ def create
+ @smtp_endpoint = @server.smtp_endpoints.build(safe_params)
+ if @smtp_endpoint.save
+ flash[:notice] = params[:return_notice] if params[:return_notice].present?
+ redirect_to_with_json [:return_to, [organization, @server, :smtp_endpoints]]
+ else
+ render_form_errors 'new', @smtp_endpoint
+ end
+ end
+
+ def update
+ if @smtp_endpoint.update(safe_params)
+ redirect_to_with_json [organization, @server, :smtp_endpoints]
+ else
+ render_form_errors 'edit', @smtp_endpoint
+ end
+ end
+
+ def destroy
+ @smtp_endpoint.destroy
+ redirect_to_with_json [organization, @server, :smtp_endpoints]
+ end
+
+ private
+
+ def safe_params
+ params.require(:smtp_endpoint).permit(:name, :hostname, :port, :ssl_mode)
+ end
+
+end
diff --git a/app/controllers/track_domains_controller.rb b/app/controllers/track_domains_controller.rb
new file mode 100644
index 0000000..369182f
--- /dev/null
+++ b/app/controllers/track_domains_controller.rb
@@ -0,0 +1,49 @@
+class TrackDomainsController < ApplicationController
+ include WithinOrganization
+ before_action { @server = organization.servers.present.find_by_permalink!(params[:server_id]) }
+ before_action { params[:id] && @track_domain = @server.track_domains.find_by_uuid!(params[:id]) }
+
+ def index
+ @track_domains = @server.track_domains.order(:name).to_a
+ end
+
+ def new
+ @track_domain = @server.track_domains.build
+ end
+
+ def create
+ @track_domain = @server.track_domains.build(params.require(:track_domain).permit(:name, :domain_id, :track_loads, :track_clicks, :excluded_click_domains, :ssl_enabled))
+ if @track_domain.save
+ redirect_to_with_json [:return_to, [organization, @server, :track_domains]]
+ else
+ render_form_errors 'new', @track_domain
+ end
+ end
+
+ def update
+ if @track_domain.update(params.require(:track_domain).permit(:track_loads, :track_clicks, :excluded_click_domains, :ssl_enabled))
+ redirect_to_with_json [organization, @server, :track_domains]
+ else
+ render_form_errors 'edit', @track_domain
+ end
+ end
+
+ def destroy
+ @track_domain.destroy
+ redirect_to_with_json [organization, @server, :track_domains]
+ end
+
+ def check
+ if @track_domain.check_dns
+ redirect_to_with_json [organization, @server, :track_domains], :notice => "Your CNAME for #{@track_domain.full_name} looks good!"
+ else
+ redirect_to_with_json [organization, @server, :track_domains], :alert => "There seems to be something wrong with your DNS record. Check documentation for information."
+ end
+ end
+
+ def toggle_ssl
+ @track_domain.update(:ssl_enabled => !@track_domain.ssl_enabled)
+ redirect_to_with_json [organization, @server, :track_domains], :notice => "SSL settings for #{@track_domain.full_name} updated successfully."
+ end
+
+end
diff --git a/app/controllers/user_controller.rb b/app/controllers/user_controller.rb
new file mode 100644
index 0000000..0a83c5d
--- /dev/null
+++ b/app/controllers/user_controller.rb
@@ -0,0 +1,85 @@
+class UserController < ApplicationController
+
+ skip_before_action :login_required, :only => [:new, :create]
+ skip_before_action :verified_email_required, :only => [:edit, :update, :verify]
+
+ def new
+ @user = User.new
+ render :layout => 'sub'
+ end
+
+ def create
+ @user = User.new(params.require(:user).permit(:first_name, :last_name, :email_address, :password, :password_confirmation))
+ if @user.save
+ AppMailer.new_user(@user).deliver
+ self.current_user = @user
+ redirect_to verify_path(:return_to => params[:return_to])
+ else
+ render 'new', :layout => 'sub'
+ end
+ end
+
+ def join
+ if @invite = UserInvite.where(:uuid => params[:token]).where("expires_at > ?", Time.now).first
+ if request.post?
+ @invite.accept(current_user)
+ redirect_to_with_json root_path(:nrd => 1), :notice => "Invitation has been accepted successfully. You now have access to this organization."
+ elsif request.delete?
+ @invite.reject
+ redirect_to_with_json root_path(:nrd => 1), :notice => "Invitation has been rejected successfully."
+ else
+ @organizations = @invite.organizations.order(:name).to_a
+ end
+ else
+ redirect_to_with_json root_path(:nrd => 1), :alert => "The invite URL you have has expired. Please ask the person who invited you to re-send your invitation."
+ end
+ end
+
+ def edit
+ @user = User.find(current_user.id)
+ end
+
+ def update
+ @user = User.find(current_user.id)
+ @user.attributes = params.require(:user).permit(:first_name, :last_name, :time_zone, :email_address, :password, :password_confirmation)
+
+ if @user.authenticate_with_previous_password_first(params[:password])
+ @password_correct = true
+ else
+ respond_to do |wants|
+ wants.html do
+ flash.now[:alert] = "The current password you have entered is incorrect. Please check and try again."
+ render 'edit'
+ end
+ wants.json do
+ render :json => {:alert => "The current password you've entered is incorrect. Please check and try again"}
+ end
+ end
+ return
+ end
+
+ email_changed = @user.email_address_changed?
+
+ if @user.save
+ if email_changed
+ redirect_to_with_json verify_path(:return_to => settings_path), :notice => "Your settings have been updated successfully. As you've changed, your e-mail address you'll need to verify it before you can continue."
+ else
+ redirect_to_with_json settings_path, :notice => "Your settings have been updated successfully."
+ end
+ else
+ render_form_errors 'edit', @user
+ end
+ end
+
+ def verify
+ if request.post?
+ if params[:code].to_s.strip == current_user.email_verification_token.to_s || (Rails.env.development? && params[:code].to_s.strip == "123456")
+ current_user.verify!
+ redirect_to_with_json [:return_to, root_path], :notice => "Thanks - your e-mail address has been verified successfully."
+ else
+ flash_now :alert, "The code you've entered isn't correct. Please check and try again."
+ end
+ end
+ end
+
+end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
new file mode 100644
index 0000000..6532992
--- /dev/null
+++ b/app/controllers/users_controller.rb
@@ -0,0 +1,63 @@
+class UsersController < ApplicationController
+ include WithinOrganization
+ before_action :require_organization_admin
+ before_action :require_organization_owner, :only => [:make_owner]
+ before_action { params[:id] && @user = params[:invite] ? organization.user_invites.find_by_uuid!(params[:id]) : organization.users.find_by_uuid!(params[:id]) }
+
+ def index
+ @users = organization.organization_users.where(:user_type => 'User').includes(:user).to_a.sort_by { |u| "#{u.user.first_name}#{u.user.last_name}".upcase }
+ @pending_users = organization.organization_users.where(:user_type => "UserInvite").includes(:user).to_a.sort_by { |u| u.user.email_address.upcase }
+ end
+
+ def new
+ @organization_user = organization.organization_users.build
+ end
+
+ def create
+ @organization_user = organization.organization_users.build(params.require(:organization_user).permit(:email_address, :admin, :all_servers))
+ if @organization_user.save
+ AppMailer.user_invite(@organization_user.user, organization).deliver
+ redirect_to_with_json [organization, :users], :notice => "An invitation will be sent to #{@organization_user.user.email_address} which will allow them to access your organization."
+ else
+ render_form_errors 'new', @organization_user
+ end
+ end
+
+ def edit
+ @organization_user = organization.user_assignment(@user)
+ end
+
+ def update
+ @organization_user = organization.user_assignment(@user)
+ if @organization_user.update(params.require(:organization_user).permit(:admin))
+ redirect_to_with_json [organization, :users], :notice => "Permissions for #{@organization_user.user.name} have been updated successfully."
+ else
+ render_form_errors 'edit', @organization_user
+ end
+ end
+
+ def destroy
+ if @user == current_user
+ redirect_to_with_json [organization, :users], :alert => "You cannot revoke your own access."
+ return
+ end
+
+ if @user == organization.owner
+ redirect_to_with_json [organization, :users], :alert => "You cannot revoke the organization owner's access."
+ return
+ end
+
+ organization.organization_users.where(:user => @user).destroy_all
+ redirect_to_with_json [organization, :users], :notice => "#{@user.name} has been removed from this organization"
+ end
+
+ def make_owner
+ if @user.is_a?(User)
+ organization.make_owner(@user)
+ redirect_to_with_json [organization, :users], :notice => "#{@user.name} is now the owner of this organization."
+ else
+ raise Postal::Error, "User must be a User not a UserInvite to make owner"
+ end
+ end
+
+end
diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb
new file mode 100644
index 0000000..12dcf91
--- /dev/null
+++ b/app/controllers/webhooks_controller.rb
@@ -0,0 +1,51 @@
+class WebhooksController < ApplicationController
+ include WithinOrganization
+ before_action { @server = organization.servers.present.find_by_permalink!(params[:server_id]) }
+ before_action { params[:id] && @webhook = @server.webhooks.find_by_uuid!(params[:id]) }
+
+ def index
+ @webhooks = @server.webhooks.order(:url).to_a
+ end
+
+ def new
+ @webhook = @server.webhooks.build(:all_events => true)
+ end
+
+ def create
+ @webhook = @server.webhooks.build(safe_params)
+ if @webhook.save
+ redirect_to_with_json [organization, @server, :webhooks]
+ else
+ render_form_errors 'new', @webhook
+ end
+ end
+
+ def update
+ if @webhook.update(safe_params)
+ redirect_to_with_json [organization, @server, :webhooks]
+ else
+ render_form_errors 'edit', @webhook
+ end
+ end
+
+ def destroy
+ @webhook.destroy
+ redirect_to_with_json [organization, @server, :webhooks]
+ end
+
+ def history
+ @current_page = params[:page] ? params[:page].to_i : 1
+ @requests = @server.message_db.webhooks.list(@current_page)
+ end
+
+ def history_request
+ @req = @server.message_db.webhooks.find(params[:uuid])
+ end
+
+ private
+
+ def safe_params
+ params.require(:webhook).permit(:name, :url, :all_events, :enabled, :events => [])
+ end
+
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
new file mode 100644
index 0000000..f80445f
--- /dev/null
+++ b/app/helpers/application_helper.rb
@@ -0,0 +1,103 @@
+module ApplicationHelper
+
+ def format_delivery_details(server, text)
+ text.gsub!(/\/) do
+ id = $1.to_i
+ link_to("message ##{id}", organization_server_message_path(server.organization, server, id), :class => "u-link")
+ end
+ text.html_safe
+ end
+
+ def style_width(width, options = {})
+ width = 100 if width > 100.0
+ width = 0 if width < 0.0
+ style = "width:#{width}%;"
+ if options[:color]
+ if width >= 100
+ style += " background-color:#e2383a;"
+ elsif width >= 90
+ style += " background-color:#e8581f;"
+ end
+ end
+ style
+ end
+
+ def domain_options_for_select(server, selected_domain = nil, options = {})
+ String.new.tap do |s|
+ s << ""
+ server_domains = server.domains.verified.order(:name)
+ unless server_domains.empty?
+ s << ""
+ end
+
+ organization_domains = server.organization.domains.verified.order(:name)
+ unless organization_domains.empty?
+ s << ""
+ end
+
+ end.html_safe
+ end
+
+ def endpoint_options_for_select(server, selected_value = nil, options = {})
+ String.new.tap do |s|
+ s << ""
+
+ http_endpoints = server.http_endpoints.order(:name).to_a
+ if http_endpoints.present?
+ s << ""
+ end
+
+
+ smtp_endpoints = server.smtp_endpoints.order(:name).to_a
+ if smtp_endpoints.present?
+ s << ""
+ end
+
+ address_endpoints = server.address_endpoints.order(:address).to_a
+ if address_endpoints.present?
+ s << ""
+ end
+
+ unless options[:other] == false
+ s << ""
+ end
+
+ end.html_safe
+ end
+
+end
diff --git a/app/jobs/action_deletion_job.rb b/app/jobs/action_deletion_job.rb
new file mode 100644
index 0000000..9b32e27
--- /dev/null
+++ b/app/jobs/action_deletion_job.rb
@@ -0,0 +1,12 @@
+class ActionDeletionJob < Postal::Job
+ def perform
+ object = params['type'].constantize.deleted.find_by_id(params['id'])
+ if object
+ log "Deleting #{params['type']}##{params['id']}"
+ object.destroy
+ log "Deleted #{params['type']}##{params['id']}"
+ else
+ log "Couldn't find deleted object #{params['type']}##{params['id']}"
+ end
+ end
+end
diff --git a/app/jobs/action_deletions_job.rb b/app/jobs/action_deletions_job.rb
new file mode 100644
index 0000000..53618a1
--- /dev/null
+++ b/app/jobs/action_deletions_job.rb
@@ -0,0 +1,13 @@
+class ActionDeletionsJob < Postal::Job
+ def perform
+ Organization.deleted.each do |org|
+ log "Permanently removing organization #{org.id} (#{org.permalink})"
+ org.destroy
+ end
+
+ Server.deleted.each do |server|
+ log "Permanently removing server #{server.id} (#{server.full_permalink})"
+ server.destroy
+ end
+ end
+end
diff --git a/app/jobs/check_all_dns_job.rb b/app/jobs/check_all_dns_job.rb
new file mode 100644
index 0000000..e4db2f5
--- /dev/null
+++ b/app/jobs/check_all_dns_job.rb
@@ -0,0 +1,13 @@
+class CheckAllDNSJob < Postal::Job
+ def perform
+ Domain.where.not(:dns_checked_at => nil).where("dns_checked_at <= ?", 1.hour.ago).each do |domain|
+ log "Checking DNS for domain: #{domain.name}"
+ domain.check_dns(:auto)
+ end
+
+ TrackDomain.where("dns_checked_at IS NULL OR dns_checked_at <= ?", 1.hour.ago).includes(:domain).each do |domain|
+ log "Checking DNS for track domain: #{domain.full_name}"
+ domain.check_dns
+ end
+ end
+end
diff --git a/app/jobs/cleanup_authie_sessions_job.rb b/app/jobs/cleanup_authie_sessions_job.rb
new file mode 100644
index 0000000..5090d46
--- /dev/null
+++ b/app/jobs/cleanup_authie_sessions_job.rb
@@ -0,0 +1,7 @@
+require 'authie/session'
+
+class CleanupAuthieSessionsJob < Postal::Job
+ def perform
+ Authie::Session.cleanup
+ end
+end
diff --git a/app/jobs/expire_held_messages_job.rb b/app/jobs/expire_held_messages_job.rb
new file mode 100644
index 0000000..7c43eed
--- /dev/null
+++ b/app/jobs/expire_held_messages_job.rb
@@ -0,0 +1,12 @@
+class ExpireHeldMessagesJob < Postal::Job
+ def perform
+ Server.all.each do |server|
+ messages = server.message_db.messages(:where => {
+ :status => 'Held',
+ :hold_expiry => {:less_than => Time.now.to_f}
+ })
+
+ messages.each(&:cancel_hold)
+ end
+ end
+end
diff --git a/app/jobs/process_message_retention_job.rb b/app/jobs/process_message_retention_job.rb
new file mode 100644
index 0000000..ce53c45
--- /dev/null
+++ b/app/jobs/process_message_retention_job.rb
@@ -0,0 +1,21 @@
+class ProcessMessageRetentionJob < Postal::Job
+ def perform
+ Server.all.each do |server|
+ if server.raw_message_retention_days
+ # If the server has a maximum number of retained raw messages, remove any that are older than this
+ log "Tidying raw messages (by days) for #{server.permalink} (ID: #{server.id}). Keeping #{server.raw_message_retention_days} days."
+ server.message_db.provisioner.remove_raw_tables_older_than(server.raw_message_retention_days)
+ end
+
+ if server.raw_message_retention_size
+ log "Tidying raw messages (by size) for #{server.permalink} (ID: #{server.id}). Keeping #{server.raw_message_retention_size} MB of data."
+ server.message_db.provisioner.remove_raw_tables_until_less_than_size(server.raw_message_retention_size * 1024 * 1024)
+ end
+
+ if server.message_retention_days
+ log "Tidying messages for #{server.permalink} (ID: #{server.id}). Keeping #{server.message_retention_days} days."
+ server.message_db.provisioner.remove_messages(server.message_retention_days)
+ end
+ end
+ end
+end
diff --git a/app/jobs/prune_suppression_lists_job.rb b/app/jobs/prune_suppression_lists_job.rb
new file mode 100644
index 0000000..8a85040
--- /dev/null
+++ b/app/jobs/prune_suppression_lists_job.rb
@@ -0,0 +1,8 @@
+class PruneSuppressionListsJob < Postal::Job
+ def perform
+ Server.all.each do |s|
+ log "Pruning suppression lists for server #{s.id}"
+ s.message_db.suppression_list.prune
+ end
+ end
+end
diff --git a/app/jobs/prune_webhook_requests_job.rb b/app/jobs/prune_webhook_requests_job.rb
new file mode 100644
index 0000000..deba1a0
--- /dev/null
+++ b/app/jobs/prune_webhook_requests_job.rb
@@ -0,0 +1,8 @@
+class PruneWebhookRequestsJob < Postal::Job
+ def perform
+ Server.all.each do |s|
+ log "Pruning webhook requests for server #{s.id}"
+ s.message_db.webhooks.prune
+ end
+ end
+end
diff --git a/app/jobs/renew_track_certificates_job.rb b/app/jobs/renew_track_certificates_job.rb
new file mode 100644
index 0000000..67b071d
--- /dev/null
+++ b/app/jobs/renew_track_certificates_job.rb
@@ -0,0 +1,15 @@
+class RenewTrackCertificatesJob < Postal::Job
+
+ def perform
+ TrackCertificate.where("renew_after IS NULL OR renew_after <= ?", Time.now).each do |certificate|
+ log "Renewing certificate for track domain ##{certificate.id} (#{certificate.domain})"
+ if certificate.get
+ log "Successfully renewed"
+ else
+ certificate.update(:renew_after => 1.day.from_now)
+ log "Could not be renewed"
+ end
+ end
+ end
+
+end
diff --git a/app/jobs/requeue_webhooks_job.rb b/app/jobs/requeue_webhooks_job.rb
new file mode 100644
index 0000000..4532656
--- /dev/null
+++ b/app/jobs/requeue_webhooks_job.rb
@@ -0,0 +1,5 @@
+class RequeueWebhooksJob < Postal::Job
+ def perform
+ WebhookRequest.requeue_all
+ end
+end
diff --git a/app/jobs/send_notifications_job.rb b/app/jobs/send_notifications_job.rb
new file mode 100644
index 0000000..00f4232
--- /dev/null
+++ b/app/jobs/send_notifications_job.rb
@@ -0,0 +1,5 @@
+class SendNotificationsJob < Postal::Job
+ def perform
+ Server.send_send_limit_notifications
+ end
+end
diff --git a/app/jobs/send_webhook_job.rb b/app/jobs/send_webhook_job.rb
new file mode 100644
index 0000000..693ca26
--- /dev/null
+++ b/app/jobs/send_webhook_job.rb
@@ -0,0 +1,28 @@
+class SendWebhookJob < Postal::Job
+
+ def perform
+ if server = Server.find(params['server_id'])
+ new_items = {}
+ if params['payload']
+ for key, value in params['payload']
+ if key.to_s =~ /\A\_(\w+)/
+ begin
+ new_items[$1] = server.message_db.message(value.to_i).webhook_hash
+ rescue Postal::MessageDB::Message::NotFound
+ end
+ end
+ end
+ end
+
+ new_items.each do |key, value|
+ params['payload'].delete("_#{key}")
+ params['payload'][key] = value
+ end
+
+ WebhookRequest.trigger(server, params['event'], params['payload'])
+ else
+ log "Couldn't find server with ID #{params['server_id']}"
+ end
+ end
+
+end
diff --git a/app/jobs/sleep_job.rb b/app/jobs/sleep_job.rb
new file mode 100644
index 0000000..ec71942
--- /dev/null
+++ b/app/jobs/sleep_job.rb
@@ -0,0 +1,5 @@
+class SleepJob < Postal::Job
+ def perform
+ sleep 5
+ end
+end
diff --git a/app/jobs/tidy_raw_messages_job.rb b/app/jobs/tidy_raw_messages_job.rb
new file mode 100644
index 0000000..e6c6374
--- /dev/null
+++ b/app/jobs/tidy_raw_messages_job.rb
@@ -0,0 +1,7 @@
+class TidyRawMessagesJob < Postal::Job
+
+ def perform
+
+ end
+
+end
diff --git a/app/jobs/unqueue_message_job.rb b/app/jobs/unqueue_message_job.rb
new file mode 100644
index 0000000..e9f95da
--- /dev/null
+++ b/app/jobs/unqueue_message_job.rb
@@ -0,0 +1,446 @@
+class UnqueueMessageJob < Postal::Job
+ def perform
+ if original_message = QueuedMessage.find_by_id(params['id'])
+ if original_message.acquire_lock
+
+ log "Lock acquired for queued message #{original_message.id}"
+
+ begin
+ original_message.message
+ rescue Postal::MessageDB::Message::NotFound
+ log "Unqueue #{original_message.id} because backend message has been removed."
+ original_message.destroy
+ return
+ end
+
+ unless original_message.retriable?
+ log "Skipping because retry after isn't reached"
+ original_message.unlock
+ return
+ end
+
+ begin
+ other_messages = original_message.batchable_messages(100)
+ log "Found #{other_messages.size} associated messages to process at the same time (batch key: #{original_message.batch_key})"
+ rescue
+ original_message.unlock
+ raise
+ end
+
+ ([original_message] + other_messages).each do |queued_message|
+ log_prefix = "[#{queued_message.server_id}::#{queued_message.message_id} #{queued_message.id}]"
+ begin
+ log "#{log_prefix} Got queued message with exclusive lock"
+
+ begin
+ queued_message.message
+ rescue Postal::MessageDB::Message::NotFound
+ log "#{log_prefix} Unqueueing #{queued_message.id} because backend message has been removed"
+ queued_message.destroy
+ next
+ end
+
+ #
+ # If the server is suspended, hold all messages
+ #
+ if queued_message.server.suspended?
+ log "#{log_prefix} Server is suspended. Holding message."
+ queued_message.message.create_delivery('Held', :details => "Mail server has been suspended. No e-mails can be processed at present. Contact support for assistance.")
+ queued_message.destroy
+ next
+ end
+
+ # We might not be able to send this any more, check the attempts
+ if queued_message.attempts >= QueuedMessage::MAX_ATTEMPTS
+ details = "Maximum number of delivery attempts (#{queued_message.attempts}) has been reached."
+ if queued_message.message.scope == 'incoming'
+ # Send bounceds to incoming e-mails when they are hard failed
+ if bounce_id = queued_message.send_bounce
+ details += " Bounce sent to sender (see message )"
+ end
+ elsif queued_message.message.scope == 'outgoing'
+ # Add the recipient to the suppression list
+ if queued_message.server.message_db.suppression_list.add(:recipient, queued_message.message.rcpt_to, :reason => "too many soft fails")
+ log "Added #{queued_message.message.rcpt_to} to suppression list because maximum attempts has been reached"
+ details += " Added #{queued_message.message.rcpt_to} to suppression list because delivery has failed #{queued_message.attempts} times."
+ end
+ end
+ queued_message.message.create_delivery('HardFail', :details => details)
+ queued_message.destroy
+ log "#{log_prefix} Message has reached maximum number of attempts. Hard failing."
+ next
+ end
+
+ # If the raw message has been removed (removed by retention)
+ unless queued_message.message.raw_message?
+ log "#{log_prefix} Raw message has been removed. Not sending."
+ queued_message.message.create_delivery('HardFail', :details => "Raw message has been removed. Cannot send message.")
+ queued_message.destroy
+ next
+ end
+
+ #
+ # Handle Incoming Messages
+ #
+ if queued_message.message.scope == 'incoming'
+ #
+ # If this is a bounce, we need to handle it as such
+ #
+ if queued_message.message.bounce == 1
+ log "#{log_prefix} Message is is a bounce"
+ original_messages = queued_message.message.original_messages
+ unless original_messages.empty?
+ for original_message in queued_message.message.original_messages
+ queued_message.message.update(:bounce_for_id => original_message.id, :domain_id => original_message.domain_id)
+ queued_message.message.create_delivery('Processed', :details => "This has been detected as a bounce message for .")
+ original_message.bounce!(queued_message.message)
+ log "#{log_prefix} Bounce linked with message #{original_message.id}"
+ end
+ queued_message.destroy
+ next
+ end
+
+ # This message was sent to the return path but hasn't been matched
+ # to an original message. If we have a route for this, route it
+ # otherwise we'll drop at this point.
+ if queued_message.message.route_id.nil?
+ log "#{log_prefix} No source messages found. Hard failing."
+ queued_message.message.create_delivery('HardFail', :details => "This message was a bounce but we couldn't link it with any outgoing message and there was no route for it.")
+ queued_message.destroy
+ next
+ end
+ end
+
+ #
+ # Update live stats
+ #
+ queued_message.message.database.live_stats.increment(queued_message.message.scope)
+
+ #
+ # Inspect incoming messages
+ #
+ if queued_message.message.inspected == 0
+ log "#{log_prefix} Inspecting message"
+ queued_message.message.inspect_message
+ if queued_message.message.inspected == 1
+ is_spam = queued_message.message.spam_score > queued_message.server.spam_threshold
+ queued_message.message.update(:spam => 1) if is_spam
+ queued_message.message.append_headers(
+ "X-Postal-Spam: #{queued_message.message.spam == 1 ? 'yes' : 'no'}",
+ "X-Postal-Spam-Threshold: #{queued_message.server.spam_threshold}",
+ "X-Postal-Spam-Score: #{queued_message.message.spam_score}",
+ "X-Postal-Threat: #{queued_message.message.threat == 1 ? 'yes' : 'no'}"
+ )
+ log "#{log_prefix} Message inspected successfully. Headers added."
+ end
+ end
+
+ #
+ # If this message has a SPAM score higher than is permitted
+ #
+ if queued_message.message.spam_score >= queued_message.server.spam_failure_threshold
+ log "#{log_prefix} Message has a spam score higher than the server's maxmimum. Hard failing."
+ queued_message.message.create_delivery('HardFail', :details => "Message's spam score is higher than the failure threshold for this server. Threshold is currently #{queued_message.server.spam_failure_threshold}.")
+ queued_message.destroy
+ next
+ end
+
+ #
+ # Find out what sort of message we're supposed to be sending and dispatch this request over to
+ # the sender.
+ #
+ if route = queued_message.message.route
+
+ # If the route says we're holding quananteed mail and this is spam, we'll hold this
+ if route.spam_mode == 'Quarantine' && queued_message.message.spam == 1 && !queued_message.manual?
+ queued_message.message.create_delivery('Held', :details => "Message placed into quarantine.")
+ queued_message.destroy
+ log "#{log_prefix} Route says to quarantine spam message. Holding."
+ next
+ end
+
+ # If the route says we're holding quananteed mail and this is spam, we'll hold this
+ if route.spam_mode == 'Fail' && queued_message.message.spam == 1 && !queued_message.manual?
+ queued_message.message.create_delivery('HardFail', :details => "Message is spam and the route specifies it should be failed.")
+ queued_message.destroy
+ log "#{log_prefix} Route says to fail spam message. Hard failing."
+ next
+ end
+
+ #
+ # Messages that should be blindly accepted are blindly accepted
+ #
+ if route.mode == 'Accept'
+ queued_message.message.create_delivery('Processed', :details => "Message has been accepted but not sent to any endpoints.")
+ queued_message.destroy
+ log "#{log_prefix} Route says to accept without endpoint. Marking as processed."
+ next
+ end
+
+ #
+ # Messages that should be accepted and held should be held
+ #
+ if route.mode == 'Hold'
+ log "#{log_prefix} Route says to hold message."
+ if queued_message.manual?
+ log "#{log_prefix} Message was queued manually. Marking as processed."
+ queued_message.message.create_delivery('Processed', :details => "Message has been processed.")
+ else
+ log "#{log_prefix} Message was not queued manually. Holding."
+ queued_message.message.create_delivery('Held', :details => "Message has been accepted but not sent to any endpoints.")
+ end
+ queued_message.destroy
+ next
+ end
+
+ #
+ # Messages that should be bounced should be bounced (or rejected if they got this far)
+ #
+ if route.mode == 'Bounce' || route.mode == 'Reject'
+ if id = queued_message.send_bounce
+ queued_message.message.create_delivery('HardFail', :details => "Message has been bounced because the route asks for this. See message ")
+ log "#{log_prefix} Route says to bounce. Hard failing and sent bounce (#{id})."
+ end
+ queued_message.destroy
+ next
+ end
+
+ begin
+ if @fixed_result
+ result = @fixed_result
+ else
+ case queued_message.message.endpoint
+ when SMTPEndpoint
+ sender = cached_sender(Postal::SMTPSender, queued_message.message.recipient_domain, nil, :servers => [queued_message.message.endpoint])
+ when HTTPEndpoint
+ sender = cached_sender(Postal::HTTPSender, queued_message.message.endpoint)
+ when AddressEndpoint
+ sender = cached_sender(Postal::SMTPSender, queued_message.message.endpoint.domain, nil, :force_rcpt_to => queued_message.message.endpoint.address)
+ else
+ log "#{log_prefix} Invalid endpoint for route (#{queued_message.message.endpoint_type})"
+ queued_message.message.create_delivery('HardFail', :details => "Invalid endpoint for route.")
+ queued_message.destroy
+ next
+ end
+ result = sender.send_message(queued_message.message)
+ if result.connect_error
+ @fixed_result = result
+ end
+ end
+ end
+
+ # Log the result
+ log_details = result.details
+ if result.type =='HardFail' && queued_message.message.send_bounces?
+ # If the message is a hard fail, send a bounce message for this message.
+ log "#{log_prefix} Sending a bounce because message hard failed"
+ if bounce_id = queued_message.send_bounce
+ log_details += ". " unless log_details =~ /\.\z/
+ log_details += " Sent bounce message to sender (see message )"
+ end
+ end
+
+ queued_message.message.create_delivery(result.type, :details => log_details, :output => result.output&.strip, :sent_with_ssl => result.secure, :log_id => result.log_id, :time => result.time)
+
+ if result.retry
+ log "#{log_prefix} Message requeued for trying later."
+ queued_message.retry_later(result.retry.is_a?(Fixnum) ? result.retry : nil)
+ else
+ log "#{log_prefix} Message processing completed."
+ queued_message.message.endpoint.mark_as_used
+ queued_message.destroy
+ end
+ else
+ log "#{log_prefix} No route and/or endpoint available for processing. Hard failing."
+ queued_message.message.create_delivery('HardFail', :details => "Message does not have a route and/or endpoint available for delivery.")
+ queued_message.destroy
+ next
+ end
+ end
+
+ #
+ # Handle Outgoing Messages
+ #
+ if queued_message.message.scope == 'outgoing'
+ if queued_message.message.domain.nil?
+ log "#{log_prefix} Message has no domain. Hard failing."
+ queued_message.message.create_delivery('HardFail', :details => "Message's domain no longer exist")
+ queued_message.destroy
+ next
+ end
+
+ #
+ # If there's no to address, we can't do much. Fail it.
+ #
+ if queued_message.message.rcpt_to.blank?
+ log "#{log_prefix} Message has no to address. Hard failing."
+ queued_message.message.create_delivery('HardFail', :details => "Message doesn't have an RCPT to")
+ queued_message.destroy
+ next
+ end
+
+ #
+ # If the credentials for this message is marked as holding and this isn't manual, hold it
+ #
+ if !queued_message.manual? && queued_message.message.credential && queued_message.message.credential.hold?
+ log "#{log_prefix} Credential wants us to hold messages. Holding."
+ queued_message.message.create_delivery('Held', :details => "Credential is configured to hold all messages authenticated by it.")
+ queued_message.destroy
+ next
+ end
+
+ #
+ # If the recipient is on the suppression list and this isn't a manual queueing block sending
+ #
+ if !queued_message.manual? && sl = queued_message.server.message_db.suppression_list.get(:recipient, queued_message.message.rcpt_to)
+ log "#{log_prefix} Recipient is on the suppression list. Holding."
+ queued_message.message.create_delivery('Held', :details => "Recipient (#{queued_message.message.rcpt_to}) is on the suppression list (reason: #{sl['reason']})")
+ queued_message.destroy
+ next
+ end
+
+ # Extract a tag and add it to the message if one doesn't exist
+ if queued_message.message.tag.nil? && tag = queued_message.message.headers['x-postal-tag']
+ log "#{log_prefix} Added tag #{tag.last}"
+ queued_message.message.update(:tag => tag.last)
+ end
+
+ # Parse the content of the message as appropriate
+ if queued_message.message.should_parse?
+ log "#{log_prefix} Parsing message content as it hasn't been parsed before"
+ queued_message.message.parse_content
+ end
+
+ # Inspect outgoing messages when there's a threshold set for the server
+ if queued_message.message.inspected == 0 && queued_message.server.outbound_spam_threshold
+ log "#{log_prefix} Inspecting message"
+ queued_message.message.inspect_message
+ if queued_message.message.inspected == 1
+ if queued_message.message.spam_score >= queued_message.server.outbound_spam_threshold
+ queued_message.message.update(:spam => 1)
+ end
+ log "#{log_prefix} Message inspected successfully"
+ end
+ end
+
+ if queued_message.message.spam == 1
+ queued_message.message.create_delivery("HardFail", :details => "Message is likely spam. Threshold is #{queued_message.server.outbound_spam_threshold} and the message scored #{queued_message.message.spam_score}.")
+ queued_message.destroy
+ log "#{log_prefix} Message is spam (#{queued_message.message.spam_score}). Hard failing."
+ next
+ end
+
+ # Add outgoing headers
+ if !queued_message.message.has_outgoing_headers?
+ queued_message.message.add_outgoing_headers
+ end
+
+ # Check send limits
+ if queued_message.server.send_limit_exceeded?
+ # If we're over the limit, we're going to be holding this message
+ queued_message.server.update_columns(:send_limit_exceeded_at => Time.now, :send_limit_approaching_at => nil)
+ queued_message.message.create_delivery('Held', :details => "Message held because send limit (#{queued_message.server.send_limit}) has been reached.")
+ queued_message.destroy
+ log "#{log_prefix} Server send limit has been exceeded. Holding."
+ next
+ elsif queued_message.server.send_limit_approaching?
+ # If we're approaching the limit, just say we are but continue to process the message
+ queued_message.server.update_columns(:send_limit_approaching_at => Time.now, :send_limit_exceeded_at => nil)
+ else
+ queued_message.server.update_columns(:send_limit_approaching_at => nil, :send_limit_exceeded_at => nil)
+ end
+
+ # Update the live stats for this message.
+ queued_message.message.database.live_stats.increment(queued_message.message.scope)
+
+ # If the server is in development mode, hold it
+ if queued_message.server.mode == 'Development' && !queued_message.manual?
+ log "Server is in development mode and this is a outgoing message so holding."
+ queued_message.message.create_delivery('Held', :details => "Server is in development mode.")
+ queued_message.destroy
+ log "#{log_prefix} Server is in development mode. Holding."
+ next
+ end
+
+ # Send the outgoing message to the SMTP sender
+ begin
+ if @fixed_result
+ result = @fixed_result
+ else
+ sender = cached_sender(Postal::SMTPSender, queued_message.message.recipient_domain, queued_message.ip_address)
+ result = sender.send_message(queued_message.message)
+ if result.connect_error
+ @fixed_result = result
+ end
+ end
+ end
+
+ #
+ # If the message has been hard failed, check to see how many other recent hard fails we've had for the address
+ # and if there are more than 2, suppress the address for 30 days.
+ #
+ if result.type == 'HardFail'
+ recent_hard_fails = queued_message.server.message_db.select(:messages, :where => {:rcpt_to => queued_message.message.rcpt_to, :status => 'HardFail', :timestamp => {:greater_than => 24.hours.ago.to_f}}, :count => true)
+ if recent_hard_fails >= 1
+ if queued_message.server.message_db.suppression_list.add(:recipient, queued_message.message.rcpt_to, :reason => "too many hard fails")
+ log "#{log_prefix} Added #{queued_message.message.rcpt_to} to suppression list because #{recent_hard_fails} hard fails in 24 hours"
+ result.details += "." if result.details =~ /\.\z/
+ result.details += " Recipient added to suppression list (too many hard fails)."
+ end
+ end
+ end
+
+ #
+ # If a message is sent successfully, remove the users from the suppression list
+ #
+ if result.type == 'Sent'
+ if queued_message.server.message_db.suppression_list.remove(:recipient, queued_message.message.rcpt_to)
+ log "#{log_prefix} Removed #{queued_message.message.rcpt_to} from suppression list because success"
+ result.details += "." if result.details =~ /\.\z/
+ result.details += " Recipient removed from suppression list."
+ end
+ end
+
+ # Log the result
+ queued_message.message.create_delivery(result.type, :details => result.details, :output => result.output, :sent_with_ssl => result.secure, :log_id => result.log_id, :time => result.time)
+ if result.retry
+ log "#{log_prefix} Message requeued for trying later."
+ queued_message.retry_later(result.retry.is_a?(Fixnum) ? result.retry : nil)
+ else
+ log "#{log_prefix} Processing complete"
+ queued_message.destroy
+ end
+ end
+
+ rescue => e
+ log "#{log_prefix} Internal error: #{e.class}: #{e.message}"
+ e.backtrace.each { |e| log("#{log_prefix} #{e}") }
+ queued_message.retry_later
+ log "#{log_prefix} Queued message was unlocked"
+ Raven.capture_exception(e, :extra => {:job_id => self.id, :server_id => queued_message.server_id, :message_id => queued_message.message_id})
+ if queued_message.message
+ queued_message.message.create_delivery("Error", :details => "An internal error occurred while sending this message. This message will be retried automatically. This this persists, contact support for assistance.", :output => "#{e.class}: #{e.message}", :log_id => "J-#{self.id}")
+ end
+ end
+ end
+
+ else
+ log "Couldn't get lock for message #{params['id']}. I won't do this."
+ end
+ else
+ log "No queued message with ID #{params['id']} was available for processing."
+ end
+ ensure
+ @sender&.finish rescue nil
+ end
+
+ private
+
+ def cached_sender(klass, *args)
+ @sender ||= begin
+ sender = klass.new(*args)
+ sender.start
+ sender
+ end
+ end
+end
diff --git a/app/jobs/webhook_delivery_job.rb b/app/jobs/webhook_delivery_job.rb
new file mode 100644
index 0000000..595c725
--- /dev/null
+++ b/app/jobs/webhook_delivery_job.rb
@@ -0,0 +1,13 @@
+class WebhookDeliveryJob < Postal::Job
+ def perform
+ if webhook_request = WebhookRequest.find_by_id(params['id'])
+ if webhook_request.deliver
+ log "Succesfully delivered"
+ else
+ log "Delivery failed"
+ end
+ else
+ log "No webhook request found with ID '#{params['id']}'"
+ end
+ end
+end
diff --git a/app/mailers/app_mailer.rb b/app/mailers/app_mailer.rb
new file mode 100644
index 0000000..a64ed32
--- /dev/null
+++ b/app/mailers/app_mailer.rb
@@ -0,0 +1,47 @@
+class AppMailer < ApplicationMailer
+
+ def verify_email_address(user)
+ @user = user
+ mail :to => @user.email_address, :subject => "Verify your new e-mail address"
+ end
+
+ def new_user(user)
+ @user = user
+ mail :to => @user.email_address, :subject => "Welcome to Postal"
+ end
+
+ def user_invite(user_invite, organization)
+ @user_invite = user_invite
+ @organization = organization
+ mail :to => @user_invite.email_address, :subject => "Access the #{organization.name} organization on Postal"
+ end
+
+ def verify_domain(domain, email_address, user)
+ @domain = domain
+ @email_address = email_address
+ @user = user
+ mail :to => email_address, :subject => "Verify your ownership of #{@domain.name}"
+ end
+
+ def password_reset(user, return_to = nil)
+ @user = user
+ @return_to = return_to
+ mail :to => @user.email_address, :subject => "Reset your Postal password"
+ end
+
+ def server_send_limit_approaching(server)
+ @server = server
+ mail :to => @server.organization.notification_addresses, :subject => "[#{server.full_permalink}] Mail server is approaching its send limit"
+ end
+
+ def server_send_limit_exceeded(server)
+ @server = server
+ mail :to => @server.organization.notification_addresses, :subject => "[#{server.full_permalink}] Mail server has exceeded its send limit"
+ end
+
+ def server_suspended(server)
+ @server = server
+ mail :to => @server.organization.notification_addresses, :subject => "[#{server.full_permalink}] Your mail server has been suspended"
+ end
+
+end
diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb
new file mode 100644
index 0000000..542129e
--- /dev/null
+++ b/app/mailers/application_mailer.rb
@@ -0,0 +1,4 @@
+class ApplicationMailer < ActionMailer::Base
+ default :from => "#{Postal.smtp_from_name} <#{Postal.smtp_from_address}>"
+ layout false
+end
diff --git a/app/models/additional_route_endpoint.rb b/app/models/additional_route_endpoint.rb
new file mode 100644
index 0000000..d94ac14
--- /dev/null
+++ b/app/models/additional_route_endpoint.rb
@@ -0,0 +1,76 @@
+# == Schema Information
+#
+# Table name: additional_route_endpoints
+#
+# id :integer not null, primary key
+# route_id :integer
+# endpoint_type :string(255)
+# endpoint_id :integer
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+
+class AdditionalRouteEndpoint < ApplicationRecord
+
+ belongs_to :route
+ belongs_to :endpoint, :polymorphic => true
+
+ validate :validate_endpoint_belongs_to_server
+ validate :validate_wildcard
+ validate :validate_uniqueness
+
+ def self.find_by_endpoint(endpoint)
+ class_name, id = endpoint.split('#', 2)
+ unless Route::ENDPOINT_TYPES.include?(class_name)
+ raise Postal::Error, "Invalid endpoint class name '#{class_name}'"
+ end
+ if uuid = class_name.constantize.find_by_uuid(id)
+ where(:endpoint_type => class_name, :endpoint_id => uuid).first
+ else
+ nil
+ end
+ end
+
+ def _endpoint
+ "#{endpoint_type}##{endpoint.uuid}"
+ end
+
+ def _endpoint=(value)
+ if value.blank?
+ self.endpoint = nil
+ else
+ if value =~ /\#/
+ class_name, id = value.split('#', 2)
+ unless Route::ENDPOINT_TYPES.include?(class_name)
+ raise Postal::Error, "Invalid endpoint class name '#{class_name}'"
+ end
+ self.endpoint = class_name.constantize.find_by_uuid(id)
+ else
+ self.endpoint = nil
+ end
+ end
+ end
+
+ private
+
+ def validate_endpoint_belongs_to_server
+ if self.endpoint && self.endpoint&.server != self.route.server
+ errors.add :endpoint, :invalid
+ end
+ end
+
+ def validate_uniqueness
+ if self.endpoint == self.route.endpoint
+ errors.add :base, "You can only add an endpoint to a route once"
+ end
+ end
+
+ def validate_wildcard
+ if self.route.wildcard?
+ if self.endpoint_type == 'SMTPEndpoint' || self.endpoint_type == 'AddressEndpoint'
+ errors.add :base, "SMTP or address endpoints are not permitted on wildcard routes"
+ end
+ end
+ end
+
+end
diff --git a/app/models/address_endpoint.rb b/app/models/address_endpoint.rb
new file mode 100644
index 0000000..dd3b193
--- /dev/null
+++ b/app/models/address_endpoint.rb
@@ -0,0 +1,42 @@
+# == Schema Information
+#
+# Table name: address_endpoints
+#
+# id :integer not null, primary key
+# server_id :integer
+# uuid :string(255)
+# address :string(255)
+# last_used_at :datetime
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+
+class AddressEndpoint < ApplicationRecord
+
+ include HasUUID
+
+ belongs_to :server
+ has_many :routes, :as => :endpoint
+ has_many :additional_route_endpoints, :dependent => :destroy, :as => :endpoint
+
+ validates :address, :presence => true, :format => {:with => /@/}, :uniqueness => {:scope => [:server_id], :message => "has already been added"}
+
+ before_destroy :update_routes
+
+ def mark_as_used
+ update_column(:last_used_at, Time.now)
+ end
+
+ def update_routes
+ self.routes.each { |r| r.update(:endpoint => nil, :mode => 'Reject') }
+ end
+
+ def description
+ self.address
+ end
+
+ def domain
+ address.split('@', 2).last
+ end
+
+end
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
new file mode 100644
index 0000000..2f70d13
--- /dev/null
+++ b/app/models/application_record.rb
@@ -0,0 +1,5 @@
+class ApplicationRecord < ActiveRecord::Base
+ self.abstract_class = true
+ self.inheritance_column = 'sti_type'
+ nilify_blanks
+end
diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/app/models/concerns/has_message.rb b/app/models/concerns/has_message.rb
new file mode 100644
index 0000000..928df64
--- /dev/null
+++ b/app/models/concerns/has_message.rb
@@ -0,0 +1,38 @@
+module HasMessage
+
+ def self.included(base)
+ base.extend ClassMethods
+ end
+
+ def message
+ @message ||= self.server.message_db.message(self.message_id)
+ end
+
+ def message=(message)
+ @message = message
+ self.message_id = message&.id
+ end
+
+ module ClassMethods
+ def include_message
+ queued_messages = all.to_a
+ server_ids = queued_messages.map(&:server_id).uniq
+ if server_ids.size == 0
+ return []
+ elsif server_ids.size > 1
+ raise Postal::Error, "'include_message' can only be used on collections of messages from the same server"
+ end
+ message_ids = queued_messages.map(&:message_id).uniq
+ server = queued_messages.first&.server
+ messages = server.message_db.messages(:where => {:id => message_ids}).each_with_object({}) do |message, hash|
+ hash[message.id] = message
+ end
+ queued_messages.each do |queued_message|
+ if m = messages[queued_message.message_id]
+ queued_message.message = m
+ end
+ end
+ end
+ end
+
+end
diff --git a/app/models/concerns/has_soft_destroy.rb b/app/models/concerns/has_soft_destroy.rb
new file mode 100644
index 0000000..b2af5fe
--- /dev/null
+++ b/app/models/concerns/has_soft_destroy.rb
@@ -0,0 +1,19 @@
+module HasSoftDestroy
+
+ def self.included(base)
+ base.define_callbacks :soft_destroy
+ base.class_eval do
+ scope :deleted, -> { where.not(:deleted_at => nil) }
+ scope :present, -> { where(:deleted_at => nil) }
+ end
+ end
+
+ def soft_destroy
+ run_callbacks :soft_destroy do
+ self.deleted_at = Time.now
+ self.save!
+ ActionDeletionJob.queue(:main, :type => self.class.name, :id => self.id)
+ end
+ end
+
+end
diff --git a/app/models/concerns/has_uuid.rb b/app/models/concerns/has_uuid.rb
new file mode 100644
index 0000000..f981bcc
--- /dev/null
+++ b/app/models/concerns/has_uuid.rb
@@ -0,0 +1,11 @@
+module HasUUID
+ def self.included(base)
+ base.class_eval do
+ random_string :uuid, :type => :uuid, :unique => true
+ end
+ end
+
+ def to_param
+ uuid
+ end
+end
diff --git a/app/models/credential.rb b/app/models/credential.rb
new file mode 100644
index 0000000..f01867f
--- /dev/null
+++ b/app/models/credential.rb
@@ -0,0 +1,57 @@
+# == Schema Information
+#
+# Table name: credentials
+#
+# id :integer not null, primary key
+# server_id :integer
+# key :string(255)
+# type :string(255)
+# name :string(255)
+# options :text(65535)
+# last_used_at :datetime
+# created_at :datetime
+# updated_at :datetime
+# hold :boolean default(FALSE)
+#
+
+class Credential < ApplicationRecord
+
+ belongs_to :server
+
+ TYPES = ['SMTP', 'API']
+
+ validates :key, :presence => true, :uniqueness => true
+ validates :type, :inclusion => {:in => TYPES}
+ validates :name, :presence => true
+
+ random_string :key, :type => :chars, :length => 24, :unique => true
+
+ serialize :options, Hash
+
+ def to_param
+ key
+ end
+
+ def use
+ update_column(:last_used_at, Time.now)
+ end
+
+ def usage_type
+ if last_used_at.nil?
+ 'Unused'
+ elsif last_used_at < 1.year.ago
+ 'Inactive'
+ elsif last_used_at < 6.months.ago
+ 'Dormant'
+ elsif last_used_at < 1.month.ago
+ 'Quiet'
+ else
+ 'Active'
+ end
+ end
+
+ def to_smtp_plain
+ Base64.encode64("\0XX\0#{self.key}").strip
+ end
+
+end
diff --git a/app/models/domain.rb b/app/models/domain.rb
new file mode 100644
index 0000000..c630ca9
--- /dev/null
+++ b/app/models/domain.rb
@@ -0,0 +1,166 @@
+# == Schema Information
+#
+# Table name: domains
+#
+# id :integer not null, primary key
+# server_id :integer
+# uuid :string(255)
+# name :string(255)
+# verification_token :string(255)
+# verification_method :string(255)
+# verified_at :datetime
+# dkim_private_key :text(65535)
+# created_at :datetime
+# updated_at :datetime
+# dns_checked_at :datetime
+# spf_status :string(255)
+# spf_error :string(255)
+# dkim_status :string(255)
+# dkim_error :string(255)
+# mx_status :string(255)
+# mx_error :string(255)
+# return_path_status :string(255)
+# return_path_error :string(255)
+# outgoing :boolean default(TRUE)
+# incoming :boolean default(TRUE)
+# owner_type :string(255)
+# owner_id :integer
+# dkim_identifier_string :string(255)
+# use_for_any :boolean
+#
+# Indexes
+#
+# index_domains_on_server_id (server_id)
+# index_domains_on_uuid (uuid)
+#
+
+class Domain < ApplicationRecord
+
+ include HasUUID
+
+ require_dependency 'domain/dns_checks'
+ require_dependency 'domain/dns_verification'
+
+ VERIFICATION_EMAIL_ALIASES = ['webmaster', 'postmaster', 'admin', 'administrator', 'hostmaster']
+
+ belongs_to :server, :optional => true
+ belongs_to :owner, :optional => true, :polymorphic => true
+ has_many :routes, :dependent => :destroy
+ has_many :track_domains, :dependent => :destroy
+
+ VERIFICATION_METHODS = ['DNS', 'Email']
+
+ validates :name, :presence => true, :format => {:with => /\A[a-z0-9\-\.]*\*?\z/}, :uniqueness => {:scope => [:owner_type, :owner_id], :message => "is already added"}
+ validates :verification_method, :inclusion => {:in => VERIFICATION_METHODS}
+
+ random_string :dkim_identifier_string, :type => :chars, :length => 6, :unique => true, :upper_letters_only => true
+
+ before_create :generate_dkim_key
+ after_create :automatically_verify_domains_in_development
+
+ scope :verified, -> { where.not(:verified_at => nil) }
+
+ when_attribute :verification_method, :changes_to => :anything do
+ before_save do
+ if self.verification_method == 'DNS'
+ self.verification_token = Nifty::Utils::RandomString.generate(:length => 32)
+ elsif self.verification_method == 'Email'
+ self.verification_token = rand(999999).to_s.ljust(6, '0')
+ else
+ self.verification_token = nil
+ end
+ end
+ end
+
+ def automatically_verify_domains_in_development
+ if Rails.env.development? && self.name && self.name =~ /\*\z/
+ self.name = self.name.gsub(/\*\z/, '')
+ self.verified_at = Time.now
+ self.verification_token = nil
+ self.save
+ end
+ end
+
+ def verified?
+ verified_at.present?
+ end
+
+ def verify
+ self.verified_at = Time.now
+ self.save!
+ end
+
+ def parent_domains
+ parts = self.name.split('.')
+ parts[0,parts.size-1].each_with_index.map do |p, i|
+ parts[i..-1].join('.')
+ end
+ end
+
+ def generate_dkim_key
+ self.dkim_private_key = OpenSSL::PKey::RSA.new(1024).to_s
+ end
+
+ def dkim_key
+ @dkim_key ||= OpenSSL::PKey::RSA.new(self.dkim_private_key)
+ end
+
+ def to_param
+ uuid
+ end
+
+ def verification_email_addresses
+ parent_domains.map do |domain|
+ VERIFICATION_EMAIL_ALIASES.map do |a|
+ "#{a}@#{domain}"
+ end
+ end.flatten
+ end
+
+ def spf_record
+ "v=spf1 a mx include:#{Postal.config.dns.spf_include} ~all"
+ end
+
+ def dkim_record
+ public_key = dkim_key.public_key.to_s.gsub(/\-+[A-Z ]+\-+\n/, '').gsub(/\n/, '')
+ "v=DKIM1; t=s; h=sha256; p=#{public_key};"
+ end
+
+ def dkim_identifier
+ Postal.config.dns.dkim_identifier + "-#{self.dkim_identifier_string}"
+ end
+
+ def dkim_record_name
+ "#{dkim_identifier}._domainkey"
+ end
+
+ def return_path_domain
+ "#{Postal.config.dns.custom_return_path_prefix}.#{self.name}"
+ end
+
+ def nameservers
+ @nameservers ||= get_nameservers
+ end
+
+ def resolver
+ @resolver ||= Resolv::DNS.new(:nameserver => nameservers)
+ end
+
+ private
+
+ def get_nameservers
+ local_resolver = Resolv::DNS.new
+ ns_records = []
+ parts = name.split('.')
+ (parts.size - 1).times do |n|
+ d = parts[n, parts.size - n + 1].join('.')
+ ns_records = local_resolver.getresources(d, Resolv::DNS::Resource::IN::NS).map { |s| s.name.to_s }
+ break unless ns_records.blank?
+ end
+ return [] if ns_records.blank?
+ ns_records = ns_records.map{|r| local_resolver.getresources(r, Resolv::DNS::Resource::IN::A).map { |s| s.address.to_s} }.flatten
+ return [] if ns_records.blank?
+ ns_records
+ end
+
+end
diff --git a/app/models/domain/dns_checks.rb b/app/models/domain/dns_checks.rb
new file mode 100644
index 0000000..1d5c0db
--- /dev/null
+++ b/app/models/domain/dns_checks.rb
@@ -0,0 +1,155 @@
+class Domain
+
+ def dns_ok?
+ spf_status == 'OK' && dkim_status == 'OK' && ['OK', 'Missing'].include?(self.mx_status) && ['OK', 'Missing'].include?(self.return_path_status)
+ end
+
+ def dns_checked?
+ spf_status.present?
+ end
+
+ def check_dns(source = :manual)
+ check_spf_record
+ check_dkim_record
+ check_mx_records
+ check_return_path_record
+ self.dns_checked_at = Time.now
+ self.save!
+ if source == :auto && !dns_ok? && self.owner.is_a?(Server)
+ WebhookRequest.trigger(self.owner, 'DomainDNSError', {
+ :server => self.owner.webhook_hash,
+ :domain => self.name,
+ :uuid => self.uuid,
+ :dns_checked_at => self.dns_checked_at.to_f,
+ :spf_status => self.spf_status,
+ :spf_error => self.spf_error,
+ :dkim_status => self.dkim_status,
+ :dkim_error => self.dkim_error,
+ :mx_status => self.mx_status,
+ :mx_error => self.mx_error,
+ :return_path_status => self.return_path_status,
+ :return_path_error => self.return_path_error
+ })
+ end
+ dns_ok?
+ end
+
+ #
+ # SPF
+ #
+
+ def check_spf_record
+ result = resolver.getresources(self.name, Resolv::DNS::Resource::IN::TXT)
+ spf_records = result.map(&:data).select { |d| d =~ /\Av=spf1/}
+ if spf_records.empty?
+ self.spf_status = 'Missing'
+ self.spf_error = 'No SPF record exists for this domain'
+ else
+ suitable_spf_records = spf_records.select { |d| d =~ /include\:\s*#{Regexp.escape(Postal.config.dns.spf_include)}/}
+ if suitable_spf_records.empty?
+ self.spf_status = 'Invalid'
+ self.spf_error = "An SPF record exists but it doesn't include #{Postal.config.dns.spf_include}"
+ false
+ else
+ self.spf_status = 'OK'
+ self.spf_error = nil
+ true
+ end
+ end
+ end
+
+ def check_spf_record!
+ check_spf_record
+ save!
+ end
+
+ #
+ # DKIM
+ #
+
+ def check_dkim_record
+ domain = "#{dkim_record_name}.#{name}"
+ result = resolver.getresources(domain, Resolv::DNS::Resource::IN::TXT)
+ records = result.map(&:data)
+ if records.empty?
+ self.dkim_status = 'Missing'
+ self.dkim_error = "No TXT records were returned for #{domain}"
+ else
+ if records.size > 1
+ self.dkim_status = 'Invalid'
+ self.dkim_error = "There are #{records.size} records for at #{domain}. There should only be one."
+ elsif records.first.strip != self.dkim_record
+ self.dkim_status = 'Invalid'
+ self.dkim_error = "The DKIM record at #{domain} does not match the record we have provided. Please check it has been copied correctly."
+ else
+ self.dkim_status = 'OK'
+ self.dkim_error = nil
+ true
+ end
+ end
+ end
+
+ def check_dkim_record!
+ check_dkim_record
+ save!
+ end
+
+ #
+ # MX
+ #
+
+ def check_mx_records
+ result = resolver.getresources(self.name, Resolv::DNS::Resource::IN::MX)
+ records = result.map(&:exchange)
+ if records.empty?
+ self.mx_status = 'Missing'
+ self.mx_error = "There are no MX records for #{self.name}"
+ else
+ missing_records = Postal.config.dns.mx_records.dup - records.map { |r| r.to_s.downcase }
+ if missing_records.empty?
+ self.mx_status = 'OK'
+ self.mx_error = nil
+ elsif missing_records.size == Postal.config.dns.mx_records.size
+ self.mx_status = 'Missing'
+ self.mx_error = 'You have MX records but none of them point to us.'
+ else
+ self.mx_status = 'Invalid'
+ self.mx_error = "MX #{missing_records.size == 1 ? 'record' : 'records'} for #{missing_records.to_sentence} are missing and are required."
+ end
+ end
+ end
+
+ def check_mx_records!
+ check_mx_records
+ save!
+ end
+
+ #
+ # Return Path
+ #
+
+ def check_return_path_record
+ result = resolver.getresources(self.return_path_domain, Resolv::DNS::Resource::IN::CNAME)
+ records = result.map { |r| r.name.to_s.downcase }
+ if records.empty?
+ self.return_path_status = 'Missing'
+ self.return_path_error = "There is no return path record at #{self.return_path_domain}"
+ else
+ if records.size == 1 && records.first == Postal.config.dns.return_path
+ self.return_path_status = 'OK'
+ self.return_path_error = nil
+ else
+ self.return_path_status = 'Invalid'
+ self.return_path_error = "There is a CNAME record at #{self.return_path_domain} but it points to #{records.first} which is incorrect. It should point to #{Postal.config.dns.return_path}."
+ end
+ end
+ end
+
+ def check_return_path_record!
+ check_return_path_record
+ save!
+ end
+
+end
+
+# -*- SkipSchemaAnnotations
diff --git a/app/models/domain/dns_verification.rb b/app/models/domain/dns_verification.rb
new file mode 100644
index 0000000..8326e2e
--- /dev/null
+++ b/app/models/domain/dns_verification.rb
@@ -0,0 +1,20 @@
+class Domain
+
+ def dns_verification_string
+ "#{Postal.config.dns.domain_verify_prefix} #{verification_token}"
+ end
+
+ def verify_with_dns
+ return false unless self.verification_method == 'DNS'
+ result = resolver.getresources(self.name, Resolv::DNS::Resource::IN::TXT)
+ if result.map { |d| d.data.to_s.strip}.include?(self.dns_verification_string)
+ self.verified_at = Time.now
+ self.save
+ else
+ false
+ end
+ end
+
+end
+
+# -*- SkipSchemaAnnotations
diff --git a/app/models/http_endpoint.rb b/app/models/http_endpoint.rb
new file mode 100644
index 0000000..d12be05
--- /dev/null
+++ b/app/models/http_endpoint.rb
@@ -0,0 +1,57 @@
+# == Schema Information
+#
+# Table name: http_endpoints
+#
+# id :integer not null, primary key
+# server_id :integer
+# uuid :string(255)
+# name :string(255)
+# url :string(255)
+# encoding :string(255)
+# format :string(255)
+# strip_replies :boolean default(FALSE)
+# error :text(65535)
+# disabled_until :datetime
+# last_used_at :datetime
+# created_at :datetime
+# updated_at :datetime
+# include_attachments :boolean default(TRUE)
+# timeout :integer
+#
+
+class HTTPEndpoint < ApplicationRecord
+
+ DEFAULT_TIMEOUT = 5
+
+ include HasUUID
+
+ belongs_to :server
+ has_many :routes, :as => :endpoint
+ has_many :additional_route_endpoints, :dependent => :destroy, :as => :endpoint
+
+ ENCODINGS = ['BodyAsJSON', 'FormData']
+ FORMATS = ['Hash', 'RawMessage']
+
+ before_destroy :update_routes
+
+ validates :name, :presence => true
+ validates :url, :presence => true
+ validates :encoding, :inclusion => {:in => ENCODINGS}
+ validates :format, :inclusion => {:in => FORMATS}
+ validates :timeout, :numericality => {:greater_than_or_equal_to => 5, :less_than_or_equal_to => 60}
+
+ default_value :timeout, -> { DEFAULT_TIMEOUT }
+
+ def description
+ "#{name} (#{url})"
+ end
+
+ def mark_as_used
+ update_column(:last_used_at, Time.now)
+ end
+
+ def update_routes
+ self.routes.each { |r| r.update(:endpoint => nil, :mode => 'Reject') }
+ end
+
+end
diff --git a/app/models/incoming_message_prototype.rb b/app/models/incoming_message_prototype.rb
new file mode 100644
index 0000000..bcd4527
--- /dev/null
+++ b/app/models/incoming_message_prototype.rb
@@ -0,0 +1,103 @@
+class IncomingMessagePrototype
+
+ attr_accessor :to
+ attr_accessor :from
+ attr_accessor :route_id
+ attr_accessor :subject
+ attr_accessor :plain_body
+ attr_accessor :attachments
+
+ def initialize(server, ip, source_type, attributes)
+ @server = server
+ @ip = ip
+ @source_type = source_type
+ @attachments = []
+ attributes.each do |key, value|
+ instance_variable_set("@#{key}", value)
+ end
+ end
+
+ def from_address
+ @from.gsub(/.*, '').gsub(/>.*/, '').strip
+ end
+
+ def route
+ @routes ||= begin
+ if @to.present?
+ uname, domain = @to.split('@', 2)
+ uname, tag = uname.split('+', 2)
+ @server.routes.includes(:domain).where(:domains => {:name => domain}, :name => uname).first
+ else
+ nil
+ end
+ end
+ end
+
+ def attachments
+ (@attachments || []).map do |attachment|
+ {
+ :name => attachment[:name],
+ :content_type => attachment[:content_type] || 'application/octet-stream',
+ :data => attachment[:base64] ? Base64.decode64(attachment[:data]) : attachment[:data]
+ }
+ end
+ end
+
+ def create_messages
+ if valid?
+ messages = route.create_messages do |message|
+ message.rcpt_to = @to
+ message.mail_from = self.from_address
+ message.raw_message = self.raw_message
+ end
+ {route.description => {:id => messages.first.id, :token => messages.first.token}}
+ else
+ false
+ end
+ end
+
+ def valid?
+ validate
+ errors.empty?
+ end
+
+ def errors
+ @errors || []
+ end
+
+ def validate
+ @errors = Array.new
+ if route.nil?
+ @errors << "NoRoutesFound"
+ end
+
+ if from.empty?
+ @errors << "FromAddressMissing"
+ end
+
+ if subject.blank?
+ @errors << "SubjectMissing"
+ end
+ @errors
+ end
+
+ def raw_message
+ @raw_message ||= begin
+ mail = Mail.new
+ mail.to = @to
+ mail.from = @from
+ mail.subject = @subject
+ mail.body = @plain_body
+ mail.message_id = "<#{SecureRandom.uuid}@#{Postal.config.dns.return_path}>"
+ attachments.each do |attachment|
+ mail.attachments[attachment[:name]] = {
+ :mime_type => attachment[:content_type],
+ :content => attachment[:data]
+ }
+ end
+ mail.header['Received'] = "from #{@source_type} (#{@ip} [#{@ip}]) by Postal with HTTP; #{Time.now.rfc2822.to_s}"
+ mail.to_s
+ end
+ end
+
+end
diff --git a/app/models/ip_address.rb b/app/models/ip_address.rb
new file mode 100644
index 0000000..c0be2bb
--- /dev/null
+++ b/app/models/ip_address.rb
@@ -0,0 +1,21 @@
+# == Schema Information
+#
+# Table name: ip_addresses
+#
+# id :integer not null, primary key
+# ip_pool_id :integer
+# ipv4 :string(255)
+# ipv6 :string(255)
+# created_at :datetime
+# updated_at :datetime
+# hostname :string(255)
+#
+
+class IPAddress < ApplicationRecord
+
+ belongs_to :ip_pool
+
+ validates :ipv4, :presence => true
+ validates :hostname, :presence => true
+
+end
diff --git a/app/models/ip_pool.rb b/app/models/ip_pool.rb
new file mode 100644
index 0000000..22e2df6
--- /dev/null
+++ b/app/models/ip_pool.rb
@@ -0,0 +1,48 @@
+# == Schema Information
+#
+# Table name: ip_pools
+#
+# id :integer not null, primary key
+# name :string(255)
+# uuid :string(255)
+# created_at :datetime
+# updated_at :datetime
+# default :boolean default(FALSE)
+# type :string(255)
+#
+# Indexes
+#
+# index_ip_pools_on_uuid (uuid)
+#
+
+class IPPool < ApplicationRecord
+
+ TYPES = ['Transactional', 'Bulk', 'Forwarding', 'Dedicated']
+
+ include HasUUID
+
+ validates :name, :presence => true
+
+ has_many :ip_addresses, :dependent => :restrict_with_exception
+ has_many :servers, :dependent => :restrict_with_exception
+ has_many :organization_ip_pools, :dependent => :destroy
+ has_many :organizations, :through => :organization_ip_pools
+
+ scope :transactional, -> { where(:type => 'Transactional') }
+ scope :bulk, -> { where(:type => 'Bulk') }
+ scope :forwarding, -> { where(:type => 'Forwarding') }
+ scope :dedicated, -> { where(:type => 'Dedicated') }
+
+ def self.default
+ where(:default => true).order(:id).first
+ end
+
+ def description
+ desc = "#{name}"
+ if self.type == 'Dedicated'
+ desc += " (#{ip_addresses.map(&:ipv4).to_sentence})"
+ end
+ desc
+ end
+
+end
diff --git a/app/models/ip_pool_rule.rb b/app/models/ip_pool_rule.rb
new file mode 100644
index 0000000..1bd8b8c
--- /dev/null
+++ b/app/models/ip_pool_rule.rb
@@ -0,0 +1,82 @@
+# == Schema Information
+#
+# Table name: ip_pool_rules
+#
+# id :integer not null, primary key
+# uuid :string(255)
+# owner_type :string(255)
+# owner_id :integer
+# ip_pool_id :integer
+# from_text :text(65535)
+# to_text :text(65535)
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+
+class IPPoolRule < ApplicationRecord
+
+ include HasUUID
+
+ belongs_to :owner, :polymorphic => true
+ belongs_to :ip_pool
+
+ validate :validate_from_and_to_addresses
+ validate :validate_ip_pool_belongs_to_organization
+
+ def from
+ from_text ? from_text.gsub(/\r/, '').split(/\n/).map(&:strip) : []
+ end
+
+ def to
+ to_text ? to_text.gsub(/\r/, '').split(/\n/).map(&:strip) : []
+ end
+
+ def apply_to_message?(message)
+ if from.present? && message.headers['from'].present?
+ from.each do |condition|
+ if message.headers['from'].any? { |f| self.class.address_matches?(condition, f) }
+ return true
+ end
+ end
+ end
+
+ if to.present? && message.rcpt_to.present?
+ to.each do |condition|
+ if self.class.address_matches?(condition, message.rcpt_to)
+ return true
+ end
+ end
+ end
+
+ false
+ end
+
+ private
+
+ def validate_from_and_to_addresses
+ if self.from.empty? && self.to.empty?
+ errors.add :base, "At least one rule condition must be specified"
+ end
+ end
+
+ def validate_ip_pool_belongs_to_organization
+ org = self.owner.is_a?(Organization) ? self.owner : self.owner.organization
+ if self.ip_pool && self.ip_pool_id_changed? && !org.ip_pools.include?(self.ip_pool)
+ errors.add :ip_pool_id, "must belong to the organization"
+ end
+ end
+
+ def self.address_matches?(condition, address)
+ address = Postal::Helpers.strip_name_from_address(address)
+ if condition =~ /@/
+ parts = address.split('@')
+ domain, uname = parts.pop, parts.join('@')
+ uname, _ = uname.split('+', 2)
+ condition == "#{uname}@#{domain}"
+ else
+ # Match as a domain
+ condition == address.split('@').last
+ end
+ end
+
+end
diff --git a/app/models/organization.rb b/app/models/organization.rb
new file mode 100644
index 0000000..c9cb986
--- /dev/null
+++ b/app/models/organization.rb
@@ -0,0 +1,118 @@
+# == Schema Information
+#
+# Table name: organizations
+#
+# id :integer not null, primary key
+# uuid :string(255)
+# name :string(255)
+# permalink :string(255)
+# time_zone :string(255)
+# created_at :datetime
+# updated_at :datetime
+# ip_pool_id :integer
+# owner_id :integer
+# deleted_at :datetime
+# suspended_at :datetime
+# suspension_reason :string(255)
+#
+# Indexes
+#
+# index_organizations_on_permalink (permalink)
+# index_organizations_on_uuid (uuid)
+#
+
+class Organization < ApplicationRecord
+
+ RESERVED_PERMALINKS = ['new', 'edit', 'remove', 'delete', 'destroy', 'admin', 'mail', 'org', 'server']
+
+ INITIAL_QUOTA = 10
+ INITIAL_SUPER_QUOTA = 10000
+ include HasUUID
+ include HasSoftDestroy
+
+ validates :name, :presence => true
+ validates :permalink, :presence => true, :format => {:with => /\A[a-z0-9\-]*\z/}, :uniqueness => true, :exclusion => {:in => RESERVED_PERMALINKS}
+ validates :time_zone, :presence => true
+
+ default_value :time_zone, -> { 'UTC' }
+ default_value :permalink, -> { Organization.find_unique_permalink(self.name) if self.name }
+
+ belongs_to :owner, :class_name => 'User'
+ has_many :organization_users, :dependent => :destroy
+ has_many :users, :through => :organization_users, :source_type => 'User'
+ has_many :user_invites, :through => :organization_users, :source_type => 'UserInvite', :source => :user
+ has_many :servers, :dependent => :destroy
+ has_many :domains, :as => :owner, :dependent => :destroy
+ has_many :organization_ip_pools, :dependent => :destroy
+ has_many :ip_pools, :through => :organization_ip_pools
+ has_many :ip_pool_rules, :dependent => :destroy, :as => :owner
+
+ after_create do
+ self.ip_pools << IPPool.transactional.default
+ end
+
+ def status
+ if self.suspended?
+ 'Suspended'
+ else
+ 'Active'
+ end
+ end
+
+ def to_param
+ permalink
+ end
+
+ def suspended?
+ suspended_at.present?
+ end
+
+ def admin?(user)
+ user.admin? ||
+ !!(owner?(user) || user_assignment(user)&.admin?)
+ end
+
+ def owner?(user)
+ self.owner == user
+ end
+
+ def accessible_by?(user)
+ user.admin? ||
+ !!(user_assignment(user))
+ end
+
+ def user_assignment(user)
+ @user_assignments ||= {}
+ @user_assignments[user.id] ||= organization_users.where(:user => user).first
+ end
+
+ def make_owner(new_owner)
+ user_assignment(new_owner).update(:admin => true, :all_servers => true)
+ update(:owner => new_owner)
+ end
+
+ # This is an array of addresses that should receive notifications for this organization
+ def notification_addresses
+ self.users.map(&:email_tag)
+ end
+
+ def self.find_unique_permalink(name)
+ loop.each_with_index do |_, i|
+ i = i + 1
+ proposal = name.parameterize
+ proposal += "-#{i}" if i > 1
+ unless self.where(:permalink => proposal).exists?
+ return proposal
+ end
+ end
+ end
+
+ def self.[](id)
+ if id.is_a?(String)
+ where(:permalink => id).first
+ else
+ where(:id => id.to_i).first
+ end
+ end
+
+end
diff --git a/app/models/organization_ip_pool.rb b/app/models/organization_ip_pool.rb
new file mode 100644
index 0000000..5456c94
--- /dev/null
+++ b/app/models/organization_ip_pool.rb
@@ -0,0 +1,15 @@
+# == Schema Information
+#
+# Table name: organization_ip_pools
+#
+# id :integer not null, primary key
+# organization_id :integer
+# ip_pool_id :integer
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+
+class OrganizationIPPool < ApplicationRecord
+ belongs_to :organization
+ belongs_to :ip_pool
+end
diff --git a/app/models/organization_user.rb b/app/models/organization_user.rb
new file mode 100644
index 0000000..1da0b3d
--- /dev/null
+++ b/app/models/organization_user.rb
@@ -0,0 +1,58 @@
+# == Schema Information
+#
+# Table name: organization_users
+#
+# id :integer not null, primary key
+# organization_id :integer
+# user_id :integer
+# created_at :datetime
+# admin :boolean default(FALSE)
+# all_servers :boolean default(TRUE)
+# user_type :string(255)
+#
+
+class OrganizationUser < ApplicationRecord
+
+ belongs_to :organization
+ belongs_to :user, :polymorphic => true, :optional => true
+
+ validate :validate_uniqueness
+
+ before_create :create_user_invite
+ after_destroy :remove_user_invites
+
+ def email_address
+ @email_address ||= user&.email_address
+ end
+
+ def email_address=(value)
+ @email_address = value
+ end
+
+ def create_user_invite
+ if self.user.nil?
+ user = UserInvite.where(:email_address => @email_address).first_or_initialize
+ if user.save
+ self.user = user
+ else
+ errors.add :base, user.errors.full_messages.to_sentence
+ throw :abort
+ end
+ end
+ end
+
+ def validate_uniqueness
+ if self.email_address.present?
+ if organization.organization_users.where.not(:id => self.id).any? { |ou| ou.user.email_address.upcase == self.email_address.upcase }
+ errors.add :email_address, "is already assigned or has an pending invite"
+ end
+ end
+ end
+
+ def remove_user_invites
+ if self.user.is_a?(UserInvite) && self.user.organizations.empty?
+ self.user.destroy
+ end
+ end
+
+end
diff --git a/app/models/outgoing_message_prototype.rb b/app/models/outgoing_message_prototype.rb
new file mode 100644
index 0000000..2f98376
--- /dev/null
+++ b/app/models/outgoing_message_prototype.rb
@@ -0,0 +1,201 @@
+class OutgoingMessagePrototype
+
+ attr_accessor :from
+ attr_accessor :sender
+ attr_accessor :to
+ attr_accessor :cc
+ attr_accessor :bcc
+ attr_accessor :subject
+ attr_accessor :reply_to
+ attr_accessor :custom_headers
+ attr_accessor :plain_body
+ attr_accessor :html_body
+ attr_accessor :attachments
+ attr_accessor :tag
+ attr_accessor :credential
+ attr_accessor :bounce
+
+ def initialize(server, ip, source_type, attributes)
+ @server = server
+ @ip = ip
+ @source_type = source_type
+ @custom_headers = {}
+ @attachments = []
+ @message_id = "#{SecureRandom.uuid}@#{Postal.config.dns.return_path}"
+ attributes.each do |key, value|
+ instance_variable_set("@#{key}", value)
+ end
+ end
+
+ def message_id
+ @message_id
+ end
+
+ def from_address
+ Postal::Helpers.strip_name_from_address(@from)
+ end
+
+ def sender_address
+ Postal::Helpers.strip_name_from_address(@sender)
+ end
+
+ def domain
+ @domain ||= begin
+ d = find_domain
+ d == :none ? nil : d
+ end
+ end
+
+ def find_domain
+ @domain ||= begin
+ domain = @server.authenticated_domain_for_address(@from)
+ if @server.allow_sender? && domain.nil?
+ domain = @server.authenticated_domain_for_address(@sender)
+ end
+ domain || :none
+ end
+ end
+
+ def to_addresses
+ @to.is_a?(String) ? @to.to_s.split(/\,\s*/) : @to.to_a
+ end
+
+ def cc_addresses
+ @cc.is_a?(String) ? @cc.to_s.split(/\,\s*/) : @cc.to_a
+ end
+
+ def bcc_addresses
+ @bcc.is_a?(String) ? @bcc.to_s.split(/\,\s*/) : @bcc.to_a
+ end
+
+ def all_addresses
+ [to_addresses, cc_addresses, bcc_addresses].flatten
+ end
+
+ def create_messages
+ if valid?
+ all_addresses.each_with_object({}) do |address, hash|
+ if address = Postal::Helpers.strip_name_from_address(address)
+ hash[address] = create_message(address)
+ end
+ end
+ else
+ false
+ end
+ end
+
+ def valid?
+ validate
+ errors.empty?
+ end
+
+ def errors
+ @errors || {}
+ end
+
+ def attachments
+ (@attachments || []).map do |attachment|
+ {
+ :name => attachment[:name],
+ :content_type => attachment[:content_type] || 'application/octet-stream',
+ :data => attachment[:base64] ? Base64.decode64(attachment[:data]) : attachment[:data]
+ }
+ end
+ end
+
+ def validate
+ @errors = Array.new
+
+ if to_addresses.empty? && cc_addresses.empty? && bcc_addresses.empty?
+ @errors << "NoRecipients"
+ end
+
+ if to_addresses.size > 50
+ @errors << 'TooManyToAddresses'
+ end
+
+ if cc_addresses.size > 50
+ @errors << 'TooManyCCAddresses'
+ end
+
+ if bcc_addresses.size > 50
+ @errors << 'TooManyBCCAddresses'
+ end
+
+ if @plain_body.blank? && @html_body.blank?
+ @errors << "NoContent"
+ end
+
+ if from.blank?
+ @errors << "FromAddressMissing"
+ end
+
+ if domain.nil?
+ @errors << "UnauthenticatedFromAddress"
+ end
+
+ if attachments && !attachments.empty?
+ attachments.each_with_index do |attachment, index|
+ if attachment[:name].blank?
+ @errors << "AttachmentMissingName" unless @errors.include?("AttachmentMissingName")
+ elsif attachment[:data].blank?
+ @errors << "AttachmentMissingData" unless @errors.include?("AttachmentMissingData")
+ end
+ end
+ end
+ @errors
+ end
+
+ def raw_message
+ @raw_message ||= begin
+ mail = Mail.new
+ if @custom_headers.is_a?(Hash)
+ @custom_headers.each { |key, value| mail[key.to_s] = value.to_s }
+ end
+ mail.to = self.to_addresses.join(', ') if self.to_addresses.present?
+ mail.cc = self.cc_addresses.join(', ') if self.cc_addresses.present?
+ mail.from = @from
+ mail.sender = @sender
+ mail.subject = @subject
+ mail.reply_to = @reply_to
+ if @html_body.blank? && attachments.empty?
+ mail.body = @plain_body
+ else
+ mail.text_part = Mail::Part.new
+ mail.text_part.body = @plain_body
+ mail.html_part = Mail::Part.new
+ mail.html_part.content_type = "text/html; charset=UTF-8"
+ mail.html_part.body = @html_body
+ end
+ attachments.each do |attachment|
+ mail.attachments[attachment[:name]] = {
+ :mime_type => attachment[:content_type],
+ :content => attachment[:data]
+ }
+ end
+ mail.header['Received'] = "from #{@source_type} (#{self.resolved_hostname} [#{@ip}]) by Postal with HTTP; #{Time.now.utc.rfc2822.to_s}"
+ mail.message_id = "<#{@message_id}>"
+ mail.to_s
+ end
+ end
+
+ def create_message(address)
+ message = @server.message_db.new_message
+ message.scope = 'outgoing'
+ message.rcpt_to = address
+ message.mail_from = self.from_address
+ message.domain_id = self.domain.id
+ message.raw_message = self.raw_message
+ message.tag = self.tag
+ message.credential_id = self.credential&.id
+ message.received_with_ssl = true
+ message.bounce = @bounce ? 1 : 0
+ message.save
+ {:id => message.id, :token => message.token}
+ end
+
+ def resolved_hostname
+ @resolved_hostname ||= Resolv.new.getname(@ip) rescue @ip
+ end
+
+end
diff --git a/app/models/queued_message.rb b/app/models/queued_message.rb
new file mode 100644
index 0000000..94bc44a
--- /dev/null
+++ b/app/models/queued_message.rb
@@ -0,0 +1,124 @@
+# == Schema Information
+#
+# Table name: queued_messages
+#
+# id :integer not null, primary key
+# server_id :integer
+# message_id :integer
+# domain :string(255)
+# locked_by :string(255)
+# locked_at :datetime
+# retry_after :datetime
+# created_at :datetime
+# updated_at :datetime
+# ip_address_id :integer
+# attempts :integer default(0)
+# route_id :integer
+# manual :boolean default(FALSE)
+# batch_key :string(255)
+#
+# Indexes
+#
+# index_queued_messages_on_domain (domain)
+# index_queued_messages_on_message_id (message_id)
+# index_queued_messages_on_server_id (server_id)
+#
+
+class QueuedMessage < ApplicationRecord
+
+ MAX_ATTEMPTS = 18
+
+ include HasMessage
+
+ belongs_to :server
+ belongs_to :ip_address, :optional => true
+ belongs_to :user, :optional => true
+
+ before_create :allocate_ip_address
+ after_commit :queue, :on => :create
+
+ scope :unlocked, -> { where(:locked_at => nil) }
+ scope :retriable, -> { where("retry_after IS NULL OR retry_after <= ?", 30.seconds.from_now) }
+
+ def retriable?
+ self.retry_after.nil? || self.retry_after <= 30.seconds.from_now
+ end
+
+ def queue
+ UnqueueMessageJob.queue(queue_name, :id => self.id)
+ end
+
+ def queue!
+ update_column(:retry_after, nil)
+ queue
+ end
+
+ def queue_name
+ ip_address ? :"outgoing-#{ip_address.id}" : :main
+ end
+
+ def send_bounce
+ if self.message.send_bounces?
+ Postal::BounceMessage.new(self.server, self.message).queue
+ end
+ end
+
+ def allocate_ip_address
+ if Postal.config.general.use_ip_pools && self.message && pool = self.server.ip_pool_for_message(self.message)
+ self.ip_address = pool.ip_addresses.order("RAND()").first
+ end
+ end
+
+ def acquire_lock
+ time = Time.now
+ locker = Postal.locker_name
+ rows = self.class.where(:id => self.id, :locked_by => nil, :locked_at => nil).update_all(:locked_by => locker, :locked_at => time)
+ if rows == 1
+ self.locked_by = locker
+ self.locked_at = time
+ true
+ else
+ false
+ end
+ end
+
+ def retry_later(time = nil)
+ retry_time = time || self.class.calculate_retry_time(self.attempts, 5.minutes)
+ self.locked_by = nil
+ self.locked_at = nil
+ update_columns(:locked_by => nil, :locked_at => nil, :retry_after => Time.now + retry_time, :attempts => self.attempts + 1)
+ end
+
+ def unlock
+ self.locked_by = nil
+ self.locked_at = nil
+ update_columns(:locked_by => nil, :locked_at => nil)
+ end
+
+ def self.calculate_retry_time(attempts, initial_period)
+ (1.3 ** attempts) * initial_period
+ end
+
+ def locked?
+ locked_at.present?
+ end
+
+ def batchable_messages(limit = 10)
+ unless locked?
+ raise Postal::Error, "Must lock current message before locking any friends"
+ end
+ if self.batch_key.nil?
+ []
+ else
+ time = Time.now
+ locker = Postal.locker_name
+ self.class.retriable.where(:batch_key => self.batch_key, :ip_address_id => self.ip_address_id, :locked_by => nil, :locked_at => nil).limit(limit).update_all(:locked_by => locker, :locked_at => time)
+ QueuedMessage.where(:batch_key => self.batch_key, :ip_address_id => self.ip_address_id, :locked_by => locker, :locked_at => time)
+ end
+ end
+
+ def self.requeue_all
+ unlocked.retriable.each(&:queue)
+ end
+
+end
diff --git a/app/models/route.rb b/app/models/route.rb
new file mode 100644
index 0000000..86d3943
--- /dev/null
+++ b/app/models/route.rb
@@ -0,0 +1,242 @@
+# == Schema Information
+#
+# Table name: routes
+#
+# id :integer not null, primary key
+# uuid :string(255)
+# server_id :integer
+# domain_id :integer
+# endpoint_id :integer
+# endpoint_type :string(255)
+# name :string(255)
+# spam_mode :string(255)
+# created_at :datetime
+# updated_at :datetime
+# token :string(255)
+# mode :string(255)
+#
+# Indexes
+#
+# index_routes_on_token (token)
+#
+
+class Route < ApplicationRecord
+
+ MODES = ['Endpoint', 'Accept', 'Hold', 'Bounce', 'Reject']
+
+ include HasUUID
+
+ belongs_to :server
+ belongs_to :domain, :optional => true
+ belongs_to :endpoint, :polymorphic => true, :optional => true
+ has_many :additional_route_endpoints, :dependent => :destroy
+
+ SPAM_MODES = ['Mark', 'Quarantine', 'Fail']
+ ENDPOINT_TYPES = ['SMTPEndpoint', 'HTTPEndpoint', 'AddressEndpoint']
+
+ validates :name, :presence => true, :format => /\A(([a-z0-9\-\.]*)|(\*)|(__returnpath__))\z/
+ validates :spam_mode, :inclusion => {:in => SPAM_MODES}
+ validates :endpoint, :presence => {:if => proc { self.mode == 'Endpoint' }}
+ validates :domain_id, :presence => {:unless => :return_path?}
+ validate :validate_route_is_routed
+ validate :validate_domain_belongs_to_server
+ validate :validate_endpoint_belongs_to_server
+ validate :validate_name_uniqueness
+ validate :validate_wildcard
+ validate :validate_return_path_route_endpoints
+ validate :validate_no_additional_routes_on_non_endpoint_route
+
+ after_save :save_additional_route_endpoints
+
+ random_string :token, :type => :chars, :length => 8, :unique => true
+
+ def return_path?
+ name == "__returnpath__"
+ end
+
+ def description
+ if return_path?
+ "Return Path"
+ else
+ "#{name}@#{domain.name}"
+ end
+ end
+
+ def _endpoint
+ @endpoint ||= begin
+ if self.mode == 'Endpoint'
+ endpoint ? "#{endpoint.class}##{endpoint.uuid}" : nil
+ else
+ self.mode
+ end
+ end
+ end
+
+ def _endpoint=(value)
+ if value.blank?
+ self.endpoint = nil
+ self.mode = nil
+ else
+ if value =~ /\#/
+ class_name, id = value.split('#', 2)
+ unless ENDPOINT_TYPES.include?(class_name)
+ raise Postal::Error, "Invalid endpoint class name '#{class_name}'"
+ end
+ self.endpoint = class_name.constantize.find_by_uuid(id)
+ self.mode = 'Endpoint'
+ else
+ self.endpoint = nil
+ self.mode = value
+ end
+ end
+ end
+
+ def forward_address
+ @forward_address ||= "#{token}@#{Postal.config.dns.route_domain}"
+ end
+
+ def wildcard?
+ self.name == '*'
+ end
+
+ def additional_route_endpoints_array
+ @additional_route_endpoints_array ||= additional_route_endpoints.map(&:_endpoint)
+ end
+
+ def additional_route_endpoints_array=(array)
+ @additional_route_endpoints_array = array.reject(&:blank?)
+ end
+
+ def save_additional_route_endpoints
+ if @additional_route_endpoints_array
+ seen = []
+ @additional_route_endpoints_array.each do |item|
+ if existing = additional_route_endpoints.find_by_endpoint(item)
+ seen << existing.id
+ else
+ route = additional_route_endpoints.build(:_endpoint => item)
+ if route.save
+ seen << route.id
+ else
+ route.errors.each do |field, message|
+ errors.add :base, message
+ end
+ raise ActiveRecord::RecordInvalid
+ end
+ end
+ end
+ additional_route_endpoints.where.not(:id => seen).destroy_all
+ end
+ end
+
+ #
+ # This message will create a suitable number of message objects for messages that
+ # are destined for this route. It receives a block which can set the message content
+ # but most information is specified already.
+ #
+ # Returns an array of created messages.
+ #
+ def create_messages(&block)
+ messages = []
+ message = self.build_message
+ if self.mode == 'Endpoint' && self.server.message_db.schema_version >= 18
+ message.endpoint_type = self.endpoint_type
+ message.endpoint_id = self.endpoint_id
+ end
+ block.call(message)
+ message.save
+ messages << message
+
+ # Also create any messages for additional endpoints that might exist
+ if self.mode == 'Endpoint' && self.server.message_db.schema_version >= 18
+ self.additional_route_endpoints.each do |endpoint|
+ next unless endpoint.endpoint
+ message = self.build_message
+ message.endpoint_id = endpoint.endpoint_id
+ message.endpoint_type = endpoint.endpoint_type
+ block.call(message)
+ message.save
+ messages << message
+ end
+ end
+
+ messages
+ end
+
+ def build_message
+ message = self.server.message_db.new_message
+ message.scope = 'incoming'
+ message.rcpt_to = self.description
+ message.domain_id = self.domain&.id
+ message.route_id = self.id
+ message
+ end
+
+ private
+
+ def validate_route_is_routed
+ if self.mode.nil?
+ errors.add :endpoint, "must be chosen"
+ end
+ end
+
+ def validate_wildcard
+ if self.wildcard?
+ if self.endpoint_type == 'SMTPEndpoint' || self.endpoint_type == 'AddressEndpoint'
+ errors.add :base, "Wildcard routes cannot be routed to SMTP servers or addresses"
+ end
+ end
+ end
+
+ def validate_domain_belongs_to_server
+ if self.domain && ![self.server, self.server.organization].include?(self.domain.owner)
+ errors.add :domain, :invalid
+ end
+
+ if self.domain && !self.domain.verified?
+ errors.add :domain, "has not been verified yet"
+ end
+ end
+
+ def validate_endpoint_belongs_to_server
+ if self.endpoint && self.endpoint&.server != self.server
+ errors.add :endpoint, :invalid
+ end
+ end
+
+ def validate_name_uniqueness
+ return if self.server.nil?
+ if self.domain
+ if route = Route.includes(:domain).where(:domains => {:name => self.domain.name}, :name => self.name).where.not(:id => self.id).first
+ errors.add :name, "is configured on the #{route.server.full_permalink} mail server"
+ end
+ else
+ if route = Route.where(:name => "__returnpath__").where.not(:id => self.id).exists?
+ errors.add :base, "A return path route already exists for this server"
+ end
+ end
+ end
+
+ def validate_return_path_route_endpoints
+ if return_path?
+ if self.mode != 'Endpoint' || self.endpoint_type != 'HTTPEndpoint'
+ errors.add :base, "Return path routes must point to an HTTP endpoint"
+ end
+ end
+ end
+
+ def validate_no_additional_routes_on_non_endpoint_route
+ if self.mode != 'Endpoint' && !self.additional_route_endpoints_array.empty?
+ errors.add :base, "Additional routes are not permitted unless the primary route is an actual endpoint"
+ end
+ end
+
+ def self.find_by_name_and_domain(name, domain)
+ route = Route.includes(:domain).where(:name => name, :domains => {:name => domain}).first
+ if route.nil?
+ route = Route.includes(:domain).where(:name => '*', :domains => {:name => domain}).first
+ end
+ route
+ end
+
+end
diff --git a/app/models/server.rb b/app/models/server.rb
new file mode 100644
index 0000000..c6c43d7
--- /dev/null
+++ b/app/models/server.rb
@@ -0,0 +1,338 @@
+# == Schema Information
+#
+# Table name: servers
+#
+# id :integer not null, primary key
+# organization_id :integer
+# uuid :string(255)
+# name :string(255)
+# mode :string(255)
+# ip_pool_id :integer
+# created_at :datetime
+# updated_at :datetime
+# permalink :string(255)
+# send_limit :integer
+# deleted_at :datetime
+# message_retention_days :integer
+# raw_message_retention_days :integer
+# raw_message_retention_size :integer
+# allow_sender :boolean default(FALSE)
+# token :string(255)
+# send_limit_approaching_at :datetime
+# send_limit_approaching_notified_at :datetime
+# send_limit_exceeded_at :datetime
+# send_limit_exceeded_notified_at :datetime
+# spam_threshold :decimal(8, 2)
+# spam_failure_threshold :decimal(8, 2)
+# postmaster_address :string(255)
+# suspended_at :datetime
+# outbound_spam_threshold :decimal(8, 2)
+# domains_not_to_click_track :text(65535)
+# suspension_reason :string(255)
+# log_smtp_data :boolean default(FALSE)
+#
+# Indexes
+#
+# index_servers_on_organization_id (organization_id)
+# index_servers_on_permalink (permalink)
+# index_servers_on_token (token)
+# index_servers_on_uuid (uuid)
+#
+
+class Server < ApplicationRecord
+
+ RESERVED_PERMALINKS = ['new', 'all', 'search', 'stats', 'edit', 'manage', 'delete', 'destroy', 'remove']
+
+ include HasUUID
+ include HasSoftDestroy
+
+ belongs_to :organization
+ belongs_to :ip_pool
+ has_many :domains, :dependent => :destroy, :as => :owner
+ has_many :credentials, :dependent => :destroy
+ has_many :smtp_endpoints, :dependent => :destroy
+ has_many :http_endpoints, :dependent => :destroy
+ has_many :address_endpoints, :dependent => :destroy
+ has_many :routes, :dependent => :destroy
+ has_many :queued_messages, :dependent => :delete_all
+ has_many :webhooks, :dependent => :destroy
+ has_many :webhook_requests, :dependent => :destroy
+ has_many :track_domains, :dependent => :destroy
+ has_many :ip_pool_rules, :dependent => :destroy, :as => :owner
+
+ MODES = ['Live', 'Development']
+
+ random_string :token, :type => :chars, :length => 6, :unique => true, :upper_letters_only => true
+ default_value :permalink, -> { name ? name.parameterize : nil}
+ default_value :send_limit, -> { 100 }
+ default_value :raw_message_retention_days, -> { 30 }
+ default_value :raw_message_retention_size, -> { 2048 }
+ default_value :message_retention_days, -> { 60 }
+ default_value :spam_threshold, -> { 5.0 }
+ default_value :spam_failure_threshold, -> { 20.0 }
+
+ validates :name, :presence => true, :uniqueness => {:scope => :organization_id}
+ validates :mode, :inclusion => {:in => MODES}
+ validates :permalink, :presence => true, :uniqueness => {:scope => :organization_id}, :format => {:with => /\A[a-z0-9\-]*\z/}, :exclusion => {:in => RESERVED_PERMALINKS}
+ validate :validate_ip_pool_belongs_to_organization
+
+ before_validation(:on => :create) do
+ self.token = self.token.downcase if self.token
+ self.outbound_spam_threshold = 3.0 if self.outbound_spam_threshold.blank?
+ end
+
+ after_create do
+ message_db.provisioner.provision
+ end
+
+ after_commit(:on => :destroy) do
+ message_db.provisioner.drop
+ end
+
+ def status
+ if self.suspended?
+ 'Suspended'
+ else
+ self.mode
+ end
+ end
+
+ def full_permalink
+ "#{organization.permalink}/#{permalink}"
+ end
+
+ def suspended?
+ suspended_at.present? || organization.suspended?
+ end
+
+ def actual_suspension_reason
+ if suspended?
+ if suspended_at.nil?
+ organization.suspension_reason
+ else
+ self.suspension_reason
+ end
+ end
+ end
+
+ def accessible_by?(user)
+ organization.accessible_by?(user)
+ end
+
+ def to_param
+ permalink
+ end
+
+ def message_db
+ @message_db ||= Postal::MessageDB::Database.new(self.organization_id, self.id)
+ end
+
+ def message(id)
+ message_db.message(id)
+ end
+
+ def message_rate
+ @message_rate ||= message_db.live_stats.total(60, :types => [:incoming, :outgoing]) / 60.0
+ end
+
+ def held_messages
+ @held_messages ||= message_db.messages(:where => {:held => 1}, :count => true)
+ end
+
+ def throughput_stats
+ @throughput_stats ||= begin
+ incoming = message_db.live_stats.total(60, :types => [:incoming])
+ outgoing = message_db.live_stats.total(60, :types => [:outgoing])
+ outgoing_usage = send_limit ? (outgoing / send_limit.to_f) * 100 : 0
+ {
+ :incoming => incoming,
+ :outgoing => outgoing,
+ :outgoing_usage => outgoing_usage
+ }
+ end
+ end
+
+ def bounce_rate
+ @bounce_rate ||= begin
+ time = Time.now.utc
+ total_outgoing = 0.0
+ total_bounces = 0.0
+ message_db.statistics.get(:daily, [:outgoing, :bounces], time, 30).each do |date, stat|
+ total_outgoing += stat[:outgoing]
+ total_bounces += stat[:bounces]
+ end
+ total_outgoing == 0 ? 0 : (total_bounces / total_outgoing) * 100
+ end
+ end
+
+ def domain_stats
+ domains = Domain.where(:owner_id => self.id, :owner_type => 'Server').to_a
+ total, unverified, bad_dns = 0, 0, 0
+ domains.each do |domain|
+ total += 1
+ unverified += 1 unless domain.verified?
+ bad_dns += 1 if domain.verified? && !domain.dns_ok?
+ end
+ [total, unverified, bad_dns]
+ end
+
+ def webhook_hash
+ {
+ :uuid => self.uuid,
+ :name => self.name,
+ :permalink => self.permalink,
+ :organization => self.organization&.permalink
+ }
+ end
+
+ def send_volume
+ @send_volume ||= message_db.live_stats.total(60, :types => [:outgoing])
+ end
+
+ def send_limit_approaching?
+ send_volume >= self.send_limit * 0.90
+ end
+
+ def send_limit_exceeded?
+ send_volume >= self.send_limit
+ end
+
+ def send_limit_warning(type)
+ AppMailer.send("server_send_limit_#{type}", self).deliver
+ self.update_column("send_limit_#{type}_notified_at", Time.now)
+ WebhookRequest.trigger(self, "SendLimit#{type.to_s.capitalize}", :server => webhook_hash, :volume => self.send_volume, :limit => self.send_limit)
+ end
+
+ def queue_size
+ @queue_size ||= queued_messages.retriable.count
+ end
+
+ def stats
+ {
+ :queue => queue_size,
+ :held => self.held_messages,
+ :bounce_rate => self.bounce_rate,
+ :message_rate => self.message_rate,
+ :throughput => self.throughput_stats,
+ :size => self.message_db.total_size
+ }
+ end
+
+ def authenticated_domain_for_address(address)
+ return nil if address.blank?
+ address = Postal::Helpers.strip_name_from_address(address)
+ uname, domain_name = address.split('@', 2)
+ return nil unless uname
+ return nil unless domain_name
+ uname, _ = uname.split('+', 2)
+
+ # Check the server's domain
+ if domain = Domain.verified.order(:owner_type => :desc).where("(owner_type = 'Organization' AND owner_id = ?) OR (owner_type = 'Server' AND owner_id = ?)", self.organization_id, self.id).where(:name => domain_name).first
+ return domain
+ end
+
+ # Check with global domains
+ if route = self.routes.includes(:domain).references(:domain).where(:domains => {:server_id => nil, :name => domain_name}, :name => uname).first
+ return route.domain
+ end
+
+ if any_domain = self.domains.verified.where(:use_for_any => true).order(:name).first
+ return any_domain
+ end
+ end
+
+ def find_authenticated_domain_from_headers(headers)
+ header_to_check = ['from']
+ header_to_check << 'sender' if self.allow_sender?
+ header_to_check.each do |header_name|
+ if headers[header_name].is_a?(Array)
+ values = headers[header_name]
+ else
+ values = [headers[header_name].to_s]
+ end
+
+ authenticated_domains = values.map { |v| authenticated_domain_for_address(v) }.compact
+ if authenticated_domains.size == values.size
+ return authenticated_domains.first
+ end
+ end
+ nil
+ end
+
+ def suspend(reason)
+ self.suspended_at = Time.now
+ self.suspension_reason = reason
+ self.save!
+ AppMailer.server_suspended(self).deliver
+ end
+
+ def unsuspend
+ self.suspended_at = nil
+ self.suspension_reason = nil
+ self.save!
+ end
+
+ def validate_ip_pool_belongs_to_organization
+ if self.ip_pool && self.ip_pool_id_changed? && (self.ip_pool.type == 'Dedicated' && !self.organization.ip_pools.include?(self.ip_pool))
+ errors.add :ip_pool_id, "must belong to the organization"
+ end
+ end
+
+ def ip_pool_for_message(message)
+ if message.scope == 'outgoing'
+
+ [self, self.organization].each do |scope|
+ rules = scope.ip_pool_rules.order(:created_at => :desc)
+ rules.each do |rule|
+ if rule.apply_to_message?(message)
+ return rule.ip_pool
+ end
+ end
+ end
+
+ self.ip_pool
+ else
+ nil
+ end
+ end
+
+ def self.triggered_send_limit(type)
+ servers = where("send_limit_#{type}_at IS NOT NULL AND send_limit_#{type}_at > ?", 3.minutes.ago)
+ servers.where("send_limit_#{type}_notified_at IS NULL OR send_limit_#{type}_notified_at < ?", 1.hour.ago)
+ end
+
+ def self.send_send_limit_notifications
+ [:approaching, :exceeded].each_with_object({}) do |type, hash|
+ hash[type] = 0
+ servers = self.triggered_send_limit(type)
+ unless servers.empty?
+ servers.each do |server|
+ hash[type] += 1
+ server.send_limit_warning(type)
+ end
+ end
+ end
+ end
+
+ def self.[](id, extra = nil)
+ server = nil
+ if id.is_a?(String)
+ if id =~ /\A(\w+)\/(\w+)\z/
+ server = includes(:organization).where(:organizations => {:permalink => $1}, :permalink => $2).first
+ end
+ else
+ server = where(:id => id).first
+ end
+
+ if extra
+ if extra.is_a?(String)
+ server.domains.where(:name => extra.to_s).first
+ else
+ server.message(extra.to_i)
+ end
+ else
+ server
+ end
+ end
+
+end
diff --git a/app/models/smtp_endpoint.rb b/app/models/smtp_endpoint.rb
new file mode 100644
index 0000000..ffbb7dc
--- /dev/null
+++ b/app/models/smtp_endpoint.rb
@@ -0,0 +1,48 @@
+# == Schema Information
+#
+# Table name: smtp_endpoints
+#
+# id :integer not null, primary key
+# server_id :integer
+# uuid :string(255)
+# name :string(255)
+# hostname :string(255)
+# ssl_mode :string(255)
+# port :integer
+# error :text(65535)
+# disabled_until :datetime
+# last_used_at :datetime
+# created_at :datetime
+# updated_at :datetime
+#
+
+class SMTPEndpoint < ApplicationRecord
+
+ include HasUUID
+
+ belongs_to :server
+ has_many :routes, :as => :endpoint
+ has_many :additional_route_endpoints, :dependent => :destroy, :as => :endpoint
+
+ SSL_MODES = ['None', 'Auto', 'STARTTLS', 'TLS']
+
+ before_destroy :update_routes
+
+ validates :name, :presence => true
+ validates :hostname, :presence => true, :format => /\A[a-z0-9\.\-]*\z/
+ validates :ssl_mode, :inclusion => {:in => SSL_MODES}
+ validates :port, :numericality => {:only_integer => true, :allow_blank => true}
+
+ def description
+ "#{name} (#{hostname})"
+ end
+
+ def mark_as_used
+ update_column(:last_used_at, Time.now)
+ end
+
+ def update_routes
+ self.routes.each { |r| r.update(:endpoint => nil, :mode => 'Reject') }
+ end
+
+end
diff --git a/app/models/statistic.rb b/app/models/statistic.rb
new file mode 100644
index 0000000..da90a4a
--- /dev/null
+++ b/app/models/statistic.rb
@@ -0,0 +1,17 @@
+# == Schema Information
+#
+# Table name: statistics
+#
+# id :integer not null, primary key
+# total_messages :integer default(0)
+# total_outgoing :integer default(0)
+# total_incoming :integer default(0)
+#
+
+class Statistic < ApplicationRecord
+
+ def self.global
+ Statistic.first || Statistic.create
+ end
+
+end
diff --git a/app/models/track_certificate.rb b/app/models/track_certificate.rb
new file mode 100644
index 0000000..246f159
--- /dev/null
+++ b/app/models/track_certificate.rb
@@ -0,0 +1,95 @@
+# == Schema Information
+#
+# Table name: track_certificates
+#
+# id :integer not null, primary key
+# domain :string(255)
+# certificate :text(65535)
+# intermediaries :text(65535)
+# key :text(65535)
+# expires_at :datetime
+# renew_after :datetime
+# verification_path :string(255)
+# verification_string :string(255)
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+# Indexes
+#
+# index_track_certificates_on_domain (domain)
+#
+
+class TrackCertificate < ApplicationRecord
+
+ validates :domain, :presence => true, :uniqueness => true
+
+ default_value :key, -> { OpenSSL::PKey::RSA.new(2048).to_s }
+
+ scope :active, -> { where("certificate IS NOT NULL AND expires_at > ?", Time.now) }
+
+ def active?
+ certificate.present?
+ end
+
+ def get
+ verify && issue
+ end
+
+ def verify
+ authorization = Postal::LetsEncrypt.client.authorize(:domain => self.domain)
+ challenge = authorization.http01
+ self.verification_path = challenge.filename
+ self.verification_string = challenge.file_content
+ self.save!
+ challenge.request_verification
+ checks = 0
+ until challenge.verify_status != "pending"
+ checks += 1
+ return false if checks > 30
+ sleep 1
+ end
+
+ unless challenge.verify_status == "valid"
+ return false
+ end
+
+ return true
+ rescue Acme::Client::Error => e
+ @retries = 0
+ if e.is_a?(Acme::Client::Error::BadNonce) && @retries < 5
+ @retries += 1
+ sleep 1
+ verify
+ else
+ return false
+ end
+ end
+
+ def issue
+ csr = OpenSSL::X509::Request.new
+ csr.subject = OpenSSL::X509::Name.new([['CN', self.domain, OpenSSL::ASN1::UTF8STRING]])
+ private_key = OpenSSL::PKey::RSA.new(self.key)
+ csr.public_key = private_key.public_key
+ csr.sign(private_key, OpenSSL::Digest::SHA256.new)
+ https_cert = Postal::LetsEncrypt.client.new_certificate(csr)
+ self.certificate = https_cert.to_pem
+ self.intermediaries = https_cert.chain_to_pem
+ self.expires_at = https_cert.x509.not_after
+ self.renew_after = (self.expires_at - 1.month) + rand(10).days
+ self.save!
+ return true
+ end
+
+ def certificate_object
+ @certificate_object ||= OpenSSL::X509::Certificate.new(self.certificate)
+ end
+
+ def intermediaries_array
+ @intermediaries_array ||= self.intermediaries.to_s.scan(/-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----/m).map{|c| OpenSSL::X509::Certificate.new(c)}
+ end
+
+ def key_object
+ @key_object ||= OpenSSL::PKey::RSA.new(self.key)
+ end
+
+end
diff --git a/app/models/track_domain.rb b/app/models/track_domain.rb
new file mode 100644
index 0000000..26b87ef
--- /dev/null
+++ b/app/models/track_domain.rb
@@ -0,0 +1,105 @@
+# == Schema Information
+#
+# Table name: track_domains
+#
+# id :integer not null, primary key
+# uuid :string(255)
+# server_id :integer
+# domain_id :integer
+# name :string(255)
+# dns_checked_at :datetime
+# dns_status :string(255)
+# dns_error :string(255)
+# created_at :datetime not null
+# updated_at :datetime not null
+# ssl_enabled :boolean default(TRUE)
+# track_clicks :boolean default(TRUE)
+# track_loads :boolean default(TRUE)
+# excluded_click_domains :text(65535)
+#
+
+class TrackDomain < ApplicationRecord
+
+ include HasUUID
+
+ belongs_to :server
+ belongs_to :domain
+
+ validates :name, :presence => true, :format => {:with => /\A[a-z0-9\-]+\z/}, :uniqueness => {:scope => :domain_id, :message => "is already added"}
+ validates :domain_id, :uniqueness => {:scope => :server_id, :message => "already has a track domain for this server"}
+ validate :validate_domain_belongs_to_server
+
+ scope :ok, -> { where(:dns_status => 'OK')}
+
+ after_create :check_dns
+ after_create :create_ssl_certificate_if_missing
+ after_destroy :delete_ssl_certificate_when_not_in_use
+
+ before_validation do
+ self.server = self.domain.server if self.domain && self.server.nil?
+ end
+
+ def full_name
+ "#{name}.#{domain.name}"
+ end
+
+ def excluded_click_domains_array
+ @excluded_click_domains_array ||= excluded_click_domains ? excluded_click_domains.split("\n").map(&:strip) : []
+ end
+
+ def dns_ok?
+ self.dns_status == 'OK'
+ end
+
+ def check_dns
+ result = self.domain.resolver.getresources(self.full_name, Resolv::DNS::Resource::IN::CNAME)
+ records = result.map { |r| r.name.to_s.downcase }
+ if records.empty?
+ self.dns_status = 'Missing'
+ self.dns_error = "There is no record at #{self.full_name}"
+ else
+ if records.size == 1 && records.first == Postal.config.dns.track_domain
+ self.dns_status = 'OK'
+ self.dns_error = nil
+ else
+ self.dns_status = 'Invalid'
+ self.dns_error = "There is a CNAME record at #{self.full_name} but it points to #{records.first} which is incorrect. It should point to #{Postal.config.dns.track_domain}."
+ end
+ end
+ self.dns_checked_at = Time.now
+ self.save!
+ dns_ok?
+ end
+
+ def has_ssl?
+ ssl_certificate && ssl_certificate.active?
+ end
+
+ def use_ssl?
+ ssl_enabled? && has_ssl?
+ end
+
+ def ssl_certificate
+ @ssl_certificate ||= TrackCertificate.where(:domain => self.full_name).first
+ end
+
+ def validate_domain_belongs_to_server
+ if self.domain && ![self.server, self.server.organization].include?(self.domain.owner)
+ errors.add :domain, "does not belong to the server or the server's organization"
+ end
+ end
+
+ def create_ssl_certificate_if_missing
+ unless TrackCertificate.where(:domain => self.full_name).exists?
+ TrackCertificate.create!(:domain => self.full_name)
+ end
+ end
+
+ def delete_ssl_certificate_when_not_in_use
+ others = TrackDomain.includes(:domain).where(:name => self.name, :domains => {:name => self.domain.name})
+ if others.empty?
+ TrackCertificate.where(:domain => self.full_name).destroy_all
+ end
+ end
+
+end
diff --git a/app/models/user.rb b/app/models/user.rb
new file mode 100644
index 0000000..520cf71
--- /dev/null
+++ b/app/models/user.rb
@@ -0,0 +1,104 @@
+# == Schema Information
+#
+# Table name: users
+#
+# id :integer not null, primary key
+# uuid :string(255)
+# first_name :string(255)
+# last_name :string(255)
+# email_address :string(255)
+# password_digest :string(255)
+# time_zone :string(255)
+# email_verification_token :string(255)
+# email_verified_at :datetime
+# created_at :datetime
+# updated_at :datetime
+# password_reset_token :string(255)
+# password_reset_token_valid_until :datetime
+# admin :boolean default(FALSE)
+#
+# Indexes
+#
+# index_users_on_email_address (email_address)
+# index_users_on_uuid (uuid)
+#
+
+class User < ApplicationRecord
+
+ include HasUUID
+
+ require_dependency 'user/authentication'
+
+ validates :first_name, :presence => true
+ validates :last_name, :presence => true
+ validates :email_address, :presence => true, :uniqueness => true, :format => {:with => /@/}
+ validates :time_zone, :presence => true
+
+ default_value :time_zone, -> { 'UTC' }
+
+ has_many :organization_users, :dependent => :destroy, :as => :user
+ has_many :organizations, :through => :organization_users
+
+ scope :verified, -> { where.not(:email_verified_at => nil) }
+
+ when_attribute :email_address, :changes_to => :anything do
+ before_save do |was, now|
+ self.email_verification_token = rand(999999).to_s.rjust(6, '0')
+ self.email_verified_at = nil
+ end
+
+ after_commit do |was, new|
+ if self.email_verified_at.nil? && was.present?
+ AppMailer.verify_email_address(self).deliver
+ end
+ end
+ end
+
+ def organizations_scope
+ @organizations_scope ||= begin
+ if self.admin?
+ Organization.present
+ else
+ self.organizations.present
+ end
+ end
+ end
+
+ def name
+ "#{first_name} #{last_name}"
+ end
+
+ def to_param
+ uuid
+ end
+
+ def verify!
+ self.email_verified_at = Time.now
+ self.save!
+ end
+
+ def verified?
+ email_verified_at.present?
+ end
+
+ def md5_for_gravatar
+ @md5_for_gravatar ||= Digest::MD5.hexdigest(email_address.to_s.downcase)
+ end
+
+ def avatar_url
+ @avatar_url ||= email_address ? "https://secure.gravatar.com/avatar/#{md5_for_gravatar}?rating=PG&size=120&d=mm" : nil
+ end
+
+ def email_tag
+ "#{name} <#{email_address}>"
+ end
+
+ def generate_login_token
+ JWT.encode({'user' => self.id, 'timestamp' => Time.now.to_f}, Postal.signing_key.to_s, 'HS256')
+ end
+
+ def self.[](email)
+ where(:email_address => email).first
+ end
+
+end
diff --git a/app/models/user/authentication.rb b/app/models/user/authentication.rb
new file mode 100644
index 0000000..0db7cdd
--- /dev/null
+++ b/app/models/user/authentication.rb
@@ -0,0 +1,52 @@
+class User
+
+ has_secure_password
+
+ validates :password, :length => {:minimum => 8, :allow_blank => true}
+
+ when_attribute :password_digest, :changes_to => :anything do
+ before_save do
+ self.password_reset_token = nil
+ self.password_reset_token_valid_until = nil
+ end
+ end
+
+ def self.authenticate(email_address, password)
+ user = where(:email_address => email_address).first
+ raise Postal::Errors::AuthenticationError.new('InvalidEmailAddress') if user.nil?
+ raise Postal::Errors::AuthenticationError.new('InvalidPassword') unless user.authenticate(password)
+ user
+ end
+
+ def authenticate_with_previous_password_first(unencrypted_password)
+ if password_digest_changed?
+ BCrypt::Password.new(password_digest_was).is_password?(unencrypted_password) && self
+ else
+ authenticate(unencrypted_password)
+ end
+ end
+
+ def begin_password_reset(return_to = nil)
+ self.password_reset_token = Nifty::Utils::RandomString.generate(:length => 24)
+ self.password_reset_token_valid_until = 1.day.from_now
+ self.save!
+ AppMailer.password_reset(self, return_to).deliver
+ end
+
+end
+
+class Postal::Errors::AuthenticationError < Postal::Error
+
+ attr_reader :error
+
+ def initialize(error)
+ @error = error
+ end
+
+ def to_s
+ "Authentication Failed: #{@error}"
+ end
+
+end
+
+# -*- SkipSchemaAnnotations
diff --git a/app/models/user_invite.rb b/app/models/user_invite.rb
new file mode 100644
index 0000000..e42d21c
--- /dev/null
+++ b/app/models/user_invite.rb
@@ -0,0 +1,54 @@
+# == Schema Information
+#
+# Table name: user_invites
+#
+# id :integer not null, primary key
+# uuid :string(255)
+# email_address :string(255)
+# expires_at :datetime
+# created_at :datetime
+# updated_at :datetime
+#
+# Indexes
+#
+# index_user_invites_on_uuid (uuid)
+#
+
+class UserInvite < ApplicationRecord
+
+ include HasUUID
+
+ validates :email_address, :presence => true, :uniqueness => true, :format => {:with => /@/, :allow_blank => true}
+
+ has_many :organization_users, :dependent => :destroy, :as => :user
+ has_many :organizations, :through => :organization_users
+
+ default_value :expires_at, -> { 7.days.from_now }
+
+ def md5_for_gravatar
+ @md5_for_gravatar ||= Digest::MD5.hexdigest(email_address.to_s.downcase)
+ end
+
+ def avatar_url
+ @avatar_url ||= email_address ? "https://secure.gravatar.com/avatar/#{md5_for_gravatar}?rating=PG&size=120&d=mm" : nil
+ end
+
+ def name
+ email_address
+ end
+
+ def accept(user)
+ transaction do
+ self.organization_users.each do |ou|
+ ou.update(:user => user) || ou.destroy
+ end
+ self.organization_users.reload
+ self.destroy
+ end
+ end
+
+ def reject
+ self.destroy
+ end
+
+end
diff --git a/app/models/webhook.rb b/app/models/webhook.rb
new file mode 100644
index 0000000..88078df
--- /dev/null
+++ b/app/models/webhook.rb
@@ -0,0 +1,60 @@
+# == Schema Information
+#
+# Table name: webhooks
+#
+# id :integer not null, primary key
+# server_id :integer
+# uuid :string(255)
+# name :string(255)
+# url :string(255)
+# last_used_at :datetime
+# all_events :boolean default(FALSE)
+# enabled :boolean default(TRUE)
+# sign :boolean default(TRUE)
+# created_at :datetime
+# updated_at :datetime
+#
+# Indexes
+#
+# index_webhooks_on_server_id (server_id)
+#
+
+class Webhook < ApplicationRecord
+
+ include HasUUID
+
+ belongs_to :server
+ has_many :webhook_events, :dependent => :destroy
+ has_many :webhook_requests
+
+ validates :name, :presence => true
+ validates :url, :presence => true, :format => {:with => /\Ahttps?\:\/\/[a-z0-9\-\.\_\?\&\/\+]+\z/i, :allow_blank => true}
+
+ scope :enabled, -> { where(:enabled => true) }
+
+ after_save :save_events
+
+ when_attribute :all_events, :changes_to => true do
+ after_save do
+ self.webhook_events.destroy_all
+ end
+ end
+
+ def events
+ @events ||= webhook_events.map(&:event)
+ end
+
+ def events=(value)
+ @events = value.map(&:to_s).select(&:present?)
+ end
+
+ def save_events
+ if @events
+ @events.each do |event|
+ webhook_events.where(:event => event).first_or_create!
+ end
+ webhook_events.where.not(:event => @events).destroy_all
+ end
+ end
+
+end
diff --git a/app/models/webhook_event.rb b/app/models/webhook_event.rb
new file mode 100644
index 0000000..a93b5c9
--- /dev/null
+++ b/app/models/webhook_event.rb
@@ -0,0 +1,34 @@
+# == Schema Information
+#
+# Table name: webhook_events
+#
+# id :integer not null, primary key
+# webhook_id :integer
+# event :string(255)
+# created_at :datetime
+#
+# Indexes
+#
+# index_webhook_events_on_webhook_id (webhook_id)
+#
+
+class WebhookEvent < ApplicationRecord
+
+ EVENTS = [
+ 'MessageSent',
+ 'MessageDelayed',
+ 'MessageDeliveryFailed',
+ 'MessageHeld',
+ 'MessageBounced',
+ 'MessageLinkClicked',
+ 'MessageLoaded',
+ 'DomainDNSError',
+ 'SendLimitApproaching',
+ 'SendLimitExceeded'
+ ]
+
+ belongs_to :webhook
+
+ validates :event, :presence => true
+
+end
diff --git a/app/models/webhook_request.rb b/app/models/webhook_request.rb
new file mode 100644
index 0000000..bbe5cca
--- /dev/null
+++ b/app/models/webhook_request.rb
@@ -0,0 +1,92 @@
+# == Schema Information
+#
+# Table name: webhook_requests
+#
+# id :integer not null, primary key
+# server_id :integer
+# webhook_id :integer
+# url :string(255)
+# event :string(255)
+# uuid :string(255)
+# payload :text(65535)
+# attempts :integer default(0)
+# retry_after :datetime
+# error :text(65535)
+# created_at :datetime
+#
+
+class WebhookRequest < ApplicationRecord
+
+ include HasUUID
+
+ RETRIES = {1 => 2.minutes, 2 => 3.minutes, 3 => 6.minutes, 4 => 10.minutes, 5 => 15.minutes}
+
+ belongs_to :server
+ belongs_to :webhook, :optional => true
+
+ validates :url, :presence => true
+ validates :event, :presence => true
+
+ serialize :payload, Hash
+
+ after_commit :queue, :on => :create
+
+ def self.trigger(server, event, payload = {})
+ unless server.is_a?(Server)
+ server = Server.find(server.to_i)
+ end
+
+ webhooks = server.webhooks.enabled.includes(:webhook_events).references(:webhook_events).where("webhooks.all_events = ? OR webhook_events.event = ?", true, event)
+ webhooks.each do |webhook|
+ server.webhook_requests.create!(:event => event, :payload => payload, :webhook => webhook, :url => webhook.url)
+ end
+ end
+
+ def self.requeue_all
+ where("retry_after < ?", Time.now).each(&:queue)
+ end
+
+ def queue
+ WebhookDeliveryJob.queue(:main, :id => self.id)
+ end
+
+ def deliver
+ logger = Postal.logger_for(:webhooks)
+ payload = {:event => self.event, :timestamp => self.created_at.to_f, :payload => self.payload, :uuid => self.uuid}.to_json
+ logger.info "[#{id}] Sending webhook request to `#{self.url}`"
+ result = Postal::HTTP.post(self.url, :sign => true, :json => payload, :timeout => 5)
+ self.attempts += 1
+ self.retry_after = RETRIES[self.attempts]&.from_now
+ self.server.message_db.webhooks.record(
+ :event => self.event,
+ :url => self.url,
+ :webhook_id => self.webhook_id,
+ :attempt => self.attempts,
+ :timestamp => Time.now.to_f,
+ :payload => self.payload.to_json,
+ :uuid => self.uuid,
+ :status_code => result[:code],
+ :body => result[:body],
+ :will_retry => (self.retry_after ? 0 : 1)
+ )
+
+ if result[:code] >= 200 && result[:code] < 300
+ logger.info "[#{id}] -> Received #{result[:code]} status code. That's OK."
+ self.destroy
+ self.webhook&.update_column(:last_used_at, Time.now)
+ true
+ else
+ logger.error "[#{id}] -> Received #{result[:code]} status code. That's not OK."
+ self.error = "Couldn't send to URL. Code received was #{result[:code]}"
+ if self.retry_after
+ logger.info "[#{id}] -> Will retry #{self.retry_after} (this was attempt #{self.attempts})"
+ self.save
+ else
+ logger.info "[#{id}] -> Have tried #{self.attempts} times. Giving up."
+ self.destroy
+ end
+ false
+ end
+ end
+
+end
diff --git a/app/views/address_endpoints/_form.html.haml b/app/views/address_endpoints/_form.html.haml
new file mode 100644
index 0000000..93bd795
--- /dev/null
+++ b/app/views/address_endpoints/_form.html.haml
@@ -0,0 +1,15 @@
+= form_for [organization, @server, @address_endpoint], :remote => true do |f|
+ = f.error_messages
+ %fieldset.fieldSet
+ .fieldSet__field
+ = f.label :address, :class => 'fieldSet__label'
+ .fieldSet__input= f.text_field :address, :autofocus => true, :class => 'input input--text'
+
+ .fieldSetSubmit.buttonSet
+ = f.submit @address_endpoint.new_record? ? "Create address endpoint" : "Save address endpoint", :class => 'button button--positive js-form-submit'
+ .fieldSetSubmit__delete
+ - if f.object.persisted?
+ = link_to "Delete address endpoint", [organization, @server, @address_endpoint], :remote => true, :class => 'button button--danger', :method => :delete, :data => {:confirm => "Are you sure you wish to delete this HTTP endpoint?\n\r#{pluralize @address_endpoint.routes.size, 'route'} that uses this endpoint will also be deleted."}
+
+ = hidden_field_tag 'return_to', params[:return_to]
+ = hidden_field_tag 'return_notice', params[:return_notice]
diff --git a/app/views/address_endpoints/edit.html.haml b/app/views/address_endpoints/edit.html.haml
new file mode 100644
index 0000000..3213899
--- /dev/null
+++ b/app/views/address_endpoints/edit.html.haml
@@ -0,0 +1,11 @@
+- page_title << @server.name
+- page_title << "Routing"
+- page_title << "Address Endpoints"
+- page_title << "Edit"
+
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :routing
+= render 'routes/header', :active_nav => :address_endpoints
+.pageContent.pageContent--compact
+ = render 'form'
+
diff --git a/app/views/address_endpoints/index.html.haml b/app/views/address_endpoints/index.html.haml
new file mode 100644
index 0000000..d1b8981
--- /dev/null
+++ b/app/views/address_endpoints/index.html.haml
@@ -0,0 +1,35 @@
+- page_title << @server.name
+- page_title << "Routing"
+- page_title << "Address Endpoints"
+
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :routing
+= render 'routes/header', :active_nav => :address_endpoints
+.pageContent.pageContent--compact
+
+ - if @address_endpoints.empty?
+ .noData.noData--koala.noData--clean
+ %h2.noData__title There aren't any address endpoints yet.
+ %p.noData__text
+ Address endpoints are e-mail addresses hosted on other platforms that you'd
+ like to deliver e-mails to. Once you've created these, you can send messages
+ to them by creating #{link_to 'routes', [organization, @server, :routes], :class => 'u-link'}.
+ %p.noData__button
+ = link_to "Add your first address endpoint", [:new, organization, @server, :address_endpoint], :class => 'button button--positive'
+
+ - else
+
+ %ul.endpointList.u-margin
+ - for endpoint in @address_endpoints
+ %li.endpointList__item
+ = link_to [:edit, organization, @server, endpoint], :class => 'endpointList__link' do
+ .endpointList__main
+ %p.endpointList__name= endpoint.address
+ %ul.endpointList__details
+ %li.endpointList__detailItem
+ - if endpoint.last_used_at
+ Last used #{distance_of_time_in_words_to_now endpoint.last_used_at} ago
+ - else
+ Not used yet
+
+ %p.u-center= link_to "Add another address endpoint", [:new, organization, @server, :address_endpoint], :class => 'button button--positive'
diff --git a/app/views/address_endpoints/new.html.haml b/app/views/address_endpoints/new.html.haml
new file mode 100644
index 0000000..921b6f1
--- /dev/null
+++ b/app/views/address_endpoints/new.html.haml
@@ -0,0 +1,11 @@
+- page_title << @server.name
+- page_title << "Routing"
+- page_title << "Address Endpoints"
+- page_title << "New"
+
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :routing
+= render 'routes/header', :active_nav => :address_endpoints
+.pageContent.pageContent--compact
+ = render 'form'
+
diff --git a/app/views/admin/organizations/index.html.haml b/app/views/admin/organizations/index.html.haml
new file mode 100644
index 0000000..16cfba3
--- /dev/null
+++ b/app/views/admin/organizations/index.html.haml
@@ -0,0 +1,24 @@
+- page_title << "Admin"
+- page_title << "Organizations"
+.pageHeader
+ %h1.pageHeader__title
+ %span.pageHeader__titlePrevious Admin →
+ Organizations
+.pageContent
+ %table.dataTable
+ %thead
+ %tr
+ %td{:width => "30%"} Name
+ %td{:width => "25%"} Owner
+ %td{:width => "5%"} Servers
+ %td{:width => "10%"} Status
+ %td{:width => "15%"} Created
+ %tbody
+ - for organization in @organizations
+ %tr
+ %td= link_to organization.name, organization_root_path(organization), :class => "u-link"
+ %td= organization.owner&.name || "No Owner"
+ %td= organization.servers.count
+ %td= organization.status
+ %td= organization.created_at.to_s(:long)
+ = paginate @organizations
diff --git a/app/views/admin/stats/stats.html.haml b/app/views/admin/stats/stats.html.haml
new file mode 100644
index 0000000..22f9f82
--- /dev/null
+++ b/app/views/admin/stats/stats.html.haml
@@ -0,0 +1,21 @@
+- page_title << "Admin"
+- page_title << "Stats"
+
+.pageContent
+ .adminStats
+ %dl.adminStats__stat
+ %dt Total Messages
+ %dd= number_with_delimiter @stats.total_messages
+
+ %dl.adminStats__stat
+ %dt Total Outgoing
+ %dd= number_with_delimiter @stats.total_outgoing
+
+
+ %dl.adminStats__stat
+ %dt Total Incoming
+ %dd= number_with_delimiter @stats.total_incoming
+
+ %dl.adminStats__stat
+ %dt Current Queue Size
+ %dd= number_with_delimiter @queue_size
diff --git a/app/views/app_mailer/new_user.text.erb b/app/views/app_mailer/new_user.text.erb
new file mode 100644
index 0000000..f7b7cd3
--- /dev/null
+++ b/app/views/app_mailer/new_user.text.erb
@@ -0,0 +1,12 @@
+Hello <%= @user.first_name %>,
+
+To verify your e-mail address you will be prompted to enter a code when you login to your Postal account. The code you need to enter is:
+
+<%= @user.email_verification_token %>
+
+Once you've done that, you'll be able to access your account.
+
+Thanks,
+
+<%= Postal.smtp_from_name %>
+<%= Postal.smtp_from_address %>
diff --git a/app/views/app_mailer/password_reset.text.erb b/app/views/app_mailer/password_reset.text.erb
new file mode 100644
index 0000000..59bdc22
--- /dev/null
+++ b/app/views/app_mailer/password_reset.text.erb
@@ -0,0 +1,12 @@
+Hello there,
+
+You (or someone pretending to be you) have requested a new password for your Postal account. To choose a new password, please click the link below and you'll be able to create a new password and login to Postal.
+
+<%= Postal.host_with_protocol %>/login/reset/<%= @user.password_reset_token %><%= @return_to ? "?return_to=#{ERB::Util.url_encode(@return_to)}" : '' %>
+
+If you didn't request this, you can ignore this e-mail.
+
+Thanks,
+
+<%= Postal.smtp_from_name %>
+<%= Postal.smtp_from_address %>
diff --git a/app/views/app_mailer/server_send_limit_approaching.text.erb b/app/views/app_mailer/server_send_limit_approaching.text.erb
new file mode 100644
index 0000000..fc9a449
--- /dev/null
+++ b/app/views/app_mailer/server_send_limit_approaching.text.erb
@@ -0,0 +1,17 @@
+We're writing to let you know that your <%= @server.name %> mail server is approaching its send limit. All mail servers have a limit of how much e-mail they are permitted to send in a rolling 60 minute window. At present you have sent <%= @server.send_volume %> messages and have a limit of <%= @server.send_limit %>.
+
+Orgaization: <%= @server.organization.name %>
+Server: <%= @server.name %>
+Send Limit: <%= @server.send_limit %>
+Current Volume: <%= @server.send_volume %>
+
+When you reach your limit, any mail you send will be held in our system until it is manually unheld by you through our web interface or using our API.
+
+You can view more information about this server at:
+
+<%= Postal.host_with_protocol %>/org/<%= @server.organization.permalink %>/servers/<%= @server.permalink %>
+
+Thanks,
+
+<%= Postal.smtp_from_name %>
+<%= Postal.smtp_from_address %>
diff --git a/app/views/app_mailer/server_send_limit_exceeded.text.erb b/app/views/app_mailer/server_send_limit_exceeded.text.erb
new file mode 100644
index 0000000..cb6eebc
--- /dev/null
+++ b/app/views/app_mailer/server_send_limit_exceeded.text.erb
@@ -0,0 +1,17 @@
+We're writing to let you know that your <%= @server.name %> mail server has exceeded its send limit. All mail servers have a limit of how much e-mail they are permitted to send in a rolling 60 minute window. At present you have sent <%= @server.send_volume %> messages and have a limit of <%= @server.send_limit %>.
+
+Orgaization: <%= @server.organization.name %>
+Server: <%= @server.name %>
+Send Limit: <%= @server.send_limit %>
+Current Volume: <%= @server.send_volume %>
+
+All messages that you send until your volume drops will now be held in our system. You will need to manually release any of these messages that you wish to send. You can do this through our web interface or using our API.
+
+You can view more information about this server at:
+
+<%= Postal.host_with_protocol %>/org/<%= @server.organization.permalink %>/servers/<%= @server.permalink %>
+
+Thanks,
+
+<%= Postal.smtp_from_name %>
+<%= Postal.smtp_from_address %>
diff --git a/app/views/app_mailer/server_suspended.text.erb b/app/views/app_mailer/server_suspended.text.erb
new file mode 100644
index 0000000..28a2f1d
--- /dev/null
+++ b/app/views/app_mailer/server_suspended.text.erb
@@ -0,0 +1,12 @@
+Hello,
+
+We're writing to inform you that, unfortunately, we have had to suspend one of your mail servers on Postal.
+
+Organization: <%= @server.organization.name %>
+Server: <%= @server.name %>
+Reason: <%= @server.actual_suspension_reason %>
+
+Thanks,
+
+<%= Postal.smtp_from_name %>
+<%= Postal.smtp_from_address %>
diff --git a/app/views/app_mailer/user_invite.text.erb b/app/views/app_mailer/user_invite.text.erb
new file mode 100644
index 0000000..1523285
--- /dev/null
+++ b/app/views/app_mailer/user_invite.text.erb
@@ -0,0 +1,10 @@
+You've been invited to access the <%= @organization.name %> organization on Postal.
+
+To accept this invitation, please click the link below to create an account or login to an existing one.
+
+<%= Postal.host_with_protocol %>/join/<%= @user_invite.uuid %>
+
+Thanks,
+
+<%= Postal.smtp_from_name %>
+<%= Postal.smtp_from_address %>
diff --git a/app/views/app_mailer/verify_domain.text.erb b/app/views/app_mailer/verify_domain.text.erb
new file mode 100644
index 0000000..43e22df
--- /dev/null
+++ b/app/views/app_mailer/verify_domain.text.erb
@@ -0,0 +1,14 @@
+Hello there,
+
+<%= @user.name %> (<%= @domain.owner.is_a?(Organization) ? @domain.owner.name : @domain.owner.organization.name %>) would like to start sending e-mail from <%= @domain.name %> using Postal. We're writing to you to request your authorization to allow this domain to be used to send e-mail through their mail server.
+
+If you agree, please provide the code below to <%= @user.first_name %> who will be able to enter it into our web interface to continue.
+
+<%= @domain.verification_token %>
+
+If you don't agree, just ignore this e-mail.
+
+Thanks,
+
+<%= Postal.smtp_from_name %>
+<%= Postal.smtp_from_address %>
diff --git a/app/views/app_mailer/verify_email_address.text.erb b/app/views/app_mailer/verify_email_address.text.erb
new file mode 100644
index 0000000..22de9ad
--- /dev/null
+++ b/app/views/app_mailer/verify_email_address.text.erb
@@ -0,0 +1,14 @@
+Hi <%= @user.first_name %>,
+
+You've just changed the e-mail address on your Postal account. So that we can verify that you own the new address you've entered, we need you to enter the code below into the box shown in your web browser.
+
+<%= @user.email_verification_token %>
+
+If you don't have a box on your screen, just login to your Postal account to continue.
+
+<%= Postal.host_with_protocol %>/login
+
+Thanks,
+
+<%= Postal.smtp_from_name %>
+<%= Postal.smtp_from_address %>
diff --git a/app/views/credentials/_form.html.haml b/app/views/credentials/_form.html.haml
new file mode 100644
index 0000000..901ece0
--- /dev/null
+++ b/app/views/credentials/_form.html.haml
@@ -0,0 +1,39 @@
+= form_for [organization, @server, @credential], :remote => true do |f|
+ = f.error_messages
+ %fieldset.fieldSet
+ .fieldSet__field
+ = f.label :type, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.select :type, Credential::TYPES, {}, :disabled => @credential.persisted?, :class => 'input input--select', :autofocus => @credential.new_record?
+ %p.fieldSet__text
+ This is the service that is associated with this credential. You'll be able to use this key to
+ authenticate to this type of service only.
+ .fieldSet__field
+ = f.label :name, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.text_field :name, :autofocus => @credential.persisted?, :class => 'input input--text'
+ %p.fieldSet__text
+ This is a friendly name so you can identify this credential later. You can enter anything
+ you want here, the more descriptive the better.
+ .fieldSet__field
+ = f.label :key, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.text_field :key, :readonly => true, :class => 'input input--text input--code', :placeholder => "Automatically generated", :tabindex => 1000, :value => (@credential.new_record? ? '' : @credential.key)
+ %p.fieldSet__text
+ This is the unique key which will be used to authenticate any requests to the API or our SMTP servers.
+ It will be generated randomly and cannot be changed. If you need a new token, you can create a new one and then
+ delete the old one when you're ready.
+ .fieldSet__field
+ = f.label :hold, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.select :hold, [["Process all messages", false], ["Hold messages from this credential", true]], {}, :class => 'input input--select'
+ %p.fieldSet__text
+ You may wish to automatically hold all messages that are sent by this credential. This allows you to preview them
+ for they are delivered to their recipients. This is useful for credentials for development environments.
+
+ .fieldSetSubmit.buttonSet
+ = f.submit @credential.new_record? ? "Create credential" : "Save credential", :class => 'button button--positive js-form-submit'
+ .fieldSetSubmit__delete
+ - if f.object.persisted?
+ = link_to "Delete credential", [organization, @server, @credential], :remote => true, :class => 'button button--danger', :method => :delete, :data => {:confirm => "Are you sure you wish to delete this credential?"}
+
diff --git a/app/views/credentials/edit.html.haml b/app/views/credentials/edit.html.haml
new file mode 100644
index 0000000..78ce820
--- /dev/null
+++ b/app/views/credentials/edit.html.haml
@@ -0,0 +1,8 @@
+- page_title << @server.name
+- page_title << "Credentials"
+- page_title << "Edit"
+
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :credentials
+.pageContent.pageContent--compact
+ = render 'form'
diff --git a/app/views/credentials/index.html.haml b/app/views/credentials/index.html.haml
new file mode 100644
index 0000000..0da3afb
--- /dev/null
+++ b/app/views/credentials/index.html.haml
@@ -0,0 +1,41 @@
+- page_title << @server.name
+- page_title << "Credentials"
+
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :credentials
+.pageContent.pageContent--compact
+ - if @credentials.empty?
+ .noData.noData--goat.noData--clean
+ %h2.noData__title This is goating out of hand. You have no credentials!
+ %p.noData__text
+ In order to authenticate to your mail server, you use credentials. Once
+ you've added a credential, you'll have a unique token which you can use to
+ authenticate against our SMTP service or our HTTP API.
+ .noData__button= link_to "Add your first credential", [:new, organization, @server, :credential], :class => 'button button--positive'
+ - else
+ %p.pageContent__intro.u-margin
+ In order to authenticate to your mail server, you use credentials. Once
+ you've added a credential, you'll have a unique token which you can use to
+ authenticate against our SMTP service or our HTTP API.
+ %p.u-margin.pageContent__helpLink= link_to "Read more about sending outgoing e-mails", [organization, @server, :help_outgoing]
+ %ul.credentialList.u-margin
+ - for credential in @credentials
+ %li.credentialList__item
+ = link_to [:edit, organization, @server, credential], :class => 'credentialList__link' do
+ .credentialList__type
+ %span.label{:class => "label--credentialType-#{credential.type.underscore}"}= credential.type
+ .credentialList__properties
+ %p.credentialList__name
+ = credential.name
+ - if credential.hold?
+ %span.label.label--red Holding
+ %p.credentialList__key= credential.key
+ .credentialList__usedAt{:class => "credentialList__usedAt--#{credential.usage_type.underscore}"}
+ - if credential.last_used_at
+ %p.credentialList__usedAtTitle= credential.usage_type
+ %p Used #{distance_of_time_in_words_to_now credential.last_used_at} ago
+ - else
+ %p Not been used yet
+
+ %p.u-center.buttonSet.buttonSet--center
+ = link_to "Add another credential", [:new, organization, @server, :credential], :class => 'button button--positive'
diff --git a/app/views/credentials/new.html.haml b/app/views/credentials/new.html.haml
new file mode 100644
index 0000000..bf16eaa
--- /dev/null
+++ b/app/views/credentials/new.html.haml
@@ -0,0 +1,8 @@
+- page_title << @server.name
+- page_title << "Credentials"
+- page_title << "Add Credential"
+
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :credentials
+.pageContent.pageContent--compact
+ = render 'form'
diff --git a/app/views/domains/_nav.html.haml b/app/views/domains/_nav.html.haml
new file mode 100644
index 0000000..0323a53
--- /dev/null
+++ b/app/views/domains/_nav.html.haml
@@ -0,0 +1,4 @@
+.navBar.navBar--secondary
+ %ul
+ %li.navBar__item= link_to "Domains", organization_server_domains_path(organization, @server), :class => ['navBar__link', active_nav == :domains ? 'is-active' : '']
+ %li.navBar__item= link_to "Tracking Domains", organization_server_track_domains_path(organization, @server), :class => ['navBar__link', active_nav == :track_domains ? 'is-active' : '']
diff --git a/app/views/domains/_verify_with_dns.html.haml b/app/views/domains/_verify_with_dns.html.haml
new file mode 100644
index 0000000..83907e1
--- /dev/null
+++ b/app/views/domains/_verify_with_dns.html.haml
@@ -0,0 +1,13 @@
+%p.pageContent__intro.u-margin
+ To verify your ownership of #{@domain.name}, you need to add a TXT record to this domain.
+ The TXT record should include the value shown below.
+
+%pre.codeBlock.u-margin= @domain.dns_verification_string
+
+%p.pageContent__intro.u-margin
+ Once you've added this, click the button below to verify the presence of this record and
+ verify your domain.
+
+.buttonSet
+ = link_to "Verify TXT record", [:verify, organization, @server, @domain], :remote => true, :method => :post, :class => "button"
+ = link_to "Back to domain list", [organization, @server, :domains], :class => "button button--neutral"
diff --git a/app/views/domains/_verify_with_email.html.haml b/app/views/domains/_verify_with_email.html.haml
new file mode 100644
index 0000000..e2c0b30
--- /dev/null
+++ b/app/views/domains/_verify_with_email.html.haml
@@ -0,0 +1,25 @@
+- if params[:email_address]
+ %p.pageContent__intro.u-margin
+ We've sent an email to #{params[:email_address]}. Please check your e-mail and enter
+ the code you've been sent in the box below.
+ = form_tag request.fullpath, :remote => true do
+ = hidden_field_tag 'email_address', params[:email_address]
+ %p.u-margin
+ = text_field_tag "code", params[:code], :autofocus => true, :class => 'input input--text js-multibox'
+ .buttonSet
+ = submit_tag "Verify this domain", :class => 'button js-form-submit'
+ = link_to "Back to domain list", [organization, @server, :domains], :class => "button button--neutral"
+
+
+- else
+ %p.pageContent__intro.u-margin
+ To verify your ownership of #{@domain.name} by e-mail, choose an e-mail address from the list
+ below. We'll then send you an email with a code which you'll need to enter below.
+
+ = form_tag request.fullpath, :remote => true do
+ %p.u-margin
+ = select_tag "email_address", options_for_select(@domain.verification_email_addresses), :class => 'input input--select', :autofocus => true
+ %p.buttonSet
+ = submit_tag "Continue", :class => 'button'
+ = link_to "Back to domain list", [organization, @server, :domains], :class => "button button--neutral"
+
diff --git a/app/views/domains/index.html.haml b/app/views/domains/index.html.haml
new file mode 100644
index 0000000..bbc9888
--- /dev/null
+++ b/app/views/domains/index.html.haml
@@ -0,0 +1,74 @@
+- if @server
+ - page_title << @server.name
+- page_title << "Domains"
+
+- if @server
+ = render 'servers/sidebar', :active_server => @server
+ = render 'servers/header', :active_nav => :domains
+ = render 'nav', :active_nav => :domains
+- else
+ .pageHeader
+ %h1.pageHeader__title
+ %span.pageHeader__titlePrevious
+ = @organization.name
+ →
+ Domains
+ = render 'organizations/nav', :active_nav => :domains
+
+.pageContent.pageContent--compact
+
+ - if @domains.empty?
+ .noData.noData--penguin.noData--clean
+ %h2.noData__title Brrrr. It's chilly in here without any domains.
+ %p.noData__text
+ To send & receive messages you need to add & verify the domain you wish to send/receive
+ messages to/from. Add your domain below to get started.
+ %p.noData__button= link_to "Add your first domain", [:new, organization, @server, :domain], :class => "button button--positive"
+
+ - else
+ %ul.domainList.u-margin
+ - for domain in @domains
+ %li.domainList__item
+ .domainList__details
+ %p.domainList__name
+ = link_to domain.name, [:setup, organization, @server, domain]
+ - if domain.use_for_any?
+ %span.label.label--blue Any
+ %ul.domainList__checks
+ - if domain.spf_status == 'OK'
+ %li.domainList__check.domainList__check--ok SPF
+ - elsif domain.spf_status.nil?
+ - else
+ %li.domainList__check.domainList__check--warning{:title => domain.spf_error}= link_to "SPF", [:setup, organization, @server, domain]
+
+ - if domain.dkim_status == 'OK'
+ %li.domainList__check.domainList__check--ok DKIM
+ - elsif domain.dkim_status.nil?
+ - else
+ %li.domainList__check.domainList__check--warning{:title => domain.dkim_error}= link_to "DKIM", [:setup, organization, @server, domain]
+
+ - if domain.mx_status == 'OK'
+ %li.domainList__check.domainList__check--ok MX
+ - elsif domain.mx_status.nil?
+ - else
+ %li.domainList__check.domainList__check--neutral-cross{:title => domain.mx_error}= link_to "MX", [:setup, organization, @server, domain]
+
+ - if domain.return_path_status == 'OK'
+ %li.domainList__check.domainList__check--ok Return Path
+ - elsif domain.return_path_status.nil?
+ - elsif domain.return_path_status == 'Missing'
+ %li.domainList__check.domainList__check--neutral{:title => domain.return_path_error}= link_to "Return Path", [:setup, organization, @server, domain]
+ - else
+ %li.domainList__check.domainList__check--warning{:title => domain.return_path_error}= link_to "Return Path", [:setup, organization, @server, domain]
+
+ %ul.domainList__properties
+ - if domain.verified?
+ %li.domainList__verificationTime Verified on #{domain.verified_at.to_s(:long)}
+ - else
+ %li= link_to "Verify this domain", [:verify, organization, @server, domain], :class => "domainList__verificationLink"
+ %li.domainList__links
+ - if domain.verified?
+ = link_to "DNS setup", [:setup, organization, @server, domain]
+ = link_to "Delete", [organization, @server, domain], :remote => :delete, :method => :delete, :data => {:confirm => "Are you sure you wish to remove this domain?", :disable_with => "Deleting..."}, :class => 'domainList__delete'
+
+ %p.u-center= link_to "Add new domain", [:new, organization, @server, :domain], :class => "button button--positive"
diff --git a/app/views/domains/new.html.haml b/app/views/domains/new.html.haml
new file mode 100644
index 0000000..d420821
--- /dev/null
+++ b/app/views/domains/new.html.haml
@@ -0,0 +1,35 @@
+- if @server
+ - page_title << @server.name
+- page_title << "Add Domain"
+
+- if @server
+ = render 'servers/sidebar', :active_server => @server
+ = render 'servers/header', :active_nav => :domains
+ = render 'nav', :active_nav => :domains
+- else
+ .pageHeader
+ %h1.pageHeader__title
+ %span.pageHeader__titlePrevious
+ = @organization.name
+ → Domains →
+ Add new domain
+ = render 'organizations/nav', :active_nav => :domains
+
+.pageContent.pageContent--compact
+ = form_for [organization, @server, @domain], :remote => true do |f|
+ = f.error_messages
+ %fieldset.fieldSet
+ .fieldSet__field
+ = f.label :name, :class => 'fieldSet__label'
+ .fieldSet__input= f.text_field :name, :autofocus => true, :class => 'input input--text'
+ .fieldSet__field
+ = f.label :verification_method, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.select :verification_method, Domain::VERIFICATION_METHODS, {}, :class => 'input input--select'
+ .fieldSet__text
+ Choose how you'd like to verify your ownership of this domain. If you choose E-Mail we can send you
+ an email with a code whcih you'll need to enter - you can choose from a set of pre-defined addresses for
+ the domain. Using DNS you'll need to add a TXT record on this domain using your DNS provider.
+
+ .fieldSetSubmit
+ = f.submit :class => "button button--positive js-form-submit"
diff --git a/app/views/domains/setup.html.haml b/app/views/domains/setup.html.haml
new file mode 100644
index 0000000..cb2f47f
--- /dev/null
+++ b/app/views/domains/setup.html.haml
@@ -0,0 +1,104 @@
+- if @server
+ - page_title << @server.name
+- page_title << @domain.name
+- page_title << "DNS Setup"
+
+
+- if @server
+ = render 'servers/sidebar', :active_server => @server
+ = render 'servers/header', :active_nav => :domains
+ = render 'nav', :active_nav => :domains
+- else
+ .pageHeader
+ %h1.pageHeader__title
+ %span.pageHeader__titlePrevious
+ = @organization.name
+ → Domains →
+ = @domain.name
+ = render 'organizations/nav', :active_nav => :domains
+
+.pageContent.pageContent--compact
+ %h2.pageContent__title DNS Setup for #{@domain.name}
+ %p.pageContent__intro.u-margin
+ Follow the instructions below to configure SPF & DKIM records for this domain.
+ We highly recommend that you do this to ensure your messages are delivered
+ correctly and quickly.
+
+ .u-margin.buttonSet
+ = link_to "Check my records are correct", [:check, organization, @server, @domain], :remote => true, :method => :post, :class => 'button'
+ = link_to "Back to domain list", [organization, @server, :domains], :class => 'button button--neutral'
+ - if @domain.dns_checked_at
+ %p.u-margin We last checked the validity of your DNS records #{distance_of_time_in_words_to_now @domain.dns_checked_at} ago.
+
+ %h3.pageContent__subTitle SPF Record
+ - if @domain.spf_status == 'OK'
+ %p.pageContent__text.u-green.u-bold
+ %span.label.label--green Good
+ Your SPF record looks good!
+ - elsif !@domain.spf_status.nil?
+ %p.pageContent__text.u-orange.u-bold
+ %span.label.label--orange Warning
+ = @domain.spf_error
+
+ %p.pageContent__text
+ You need to add a TXT record at the apex/root of your domain (@) with the following
+ content. If you already send mail from another service, you may just need to add
+ include:#{Postal.config.dns.spf_include} to your existing record.
+ %pre.codeBlock.u-margin= @domain.spf_record
+
+ %h3.pageContent__subTitle DKIM Record
+ - if @domain.dkim_status == 'OK'
+ %p.pageContent__text.u-green.u-bold
+ %span.label.label--green Good
+ Your DKIM record looks good!
+ - elsif !@domain.dkim_status.nil?
+ %p.pageContent__text.u-orange.u-bold
+ %span.label.label--orange Warning
+ = @domain.dkim_error
+
+ %p.pageContent__text
+ You need to add a new TXT record with the name #{@domain.dkim_record_name}
+ with the following content.
+ %pre.codeBlock.u-margin= @domain.dkim_record
+
+ %h3.pageContent__subTitle Return Path
+ - if @domain.return_path_status == 'OK'
+ %p.pageContent__text.u-green.u-bold
+ %span.label.label--green Good
+ Your return path looks good. We'll use this when sending e-mail from this domain.
+ - elsif @domain.return_path_status == 'Missing'
+ %p.pageContent__text.u-grey.u-bold
+ %span.label.label--grey OK
+ There's no return path for this domain. This is OK but we recommend adding the record to improve deliverability and achieve DMARC alignment.
+ - elsif !@domain.return_path_status.nil?
+ %p.pageContent__text.u-orange.u-bold
+ %span.label.label--orange Warning
+ = @domain.return_path_error
+
+ %p.pageContent__text
+ This is optional but we recommend adding this to improve deliverability. You should add
+ a CNAME record at #{@domain.return_path_domain} to point to the hostname below.
+ %pre.codeBlock.u-margin= Postal.config.dns.return_path
+
+
+ %h3.pageContent__subTitle MX Records
+ - if @domain.mx_status == 'OK'
+ %p.pageContent__text.u-green.u-bold
+ %span.label.label--green Good
+ Your MX records look like they're good to go!
+ - elsif @domain.mx_status == 'Missing'
+ %p.pageContent__text.u-grey.u-bold
+ %span.label.label--grey OK
+ None of the MX records for this domain point to us. Incoming mail won't be sent to us.
+ - elsif !@domain.mx_status.nil?
+ %p.pageContent__text.u-orange.u-bold
+ %span.label.label--orange Warning
+ = @domain.mx_error
+
+ %p.pageContent__text
+ If you wish to receive incoming e-mail for this domain, you need to add the following MX records
+ to the domain. You don't have to do this and we'll only tell you if they're set up or not. Both
+ records should be priority 10.
+ %pre.codeBlock.u-margin= Postal.config.dns.mx_records.join("\n")
+
+
diff --git a/app/views/domains/verify.html.haml b/app/views/domains/verify.html.haml
new file mode 100644
index 0000000..4acf03f
--- /dev/null
+++ b/app/views/domains/verify.html.haml
@@ -0,0 +1,21 @@
+- if @server
+ - page_title << @server.name
+- page_title << @domain.name
+- page_title << "Verify"
+
+
+- if @server
+ = render 'servers/sidebar', :active_server => @server
+ = render 'servers/header', :active_nav => :domains
+ = render 'nav', :active_nav => :domains
+- else
+ .pageHeader
+ %h1.pageHeader__title
+ %span.pageHeader__titlePrevious
+ = @organization.name
+ → Domains →
+ = @domain.name
+ = render 'organizations/nav', :active_nav => :domains
+
+.pageContent.pageContent--compact
+ = render :partial => "verify_with_#{@domain.verification_method.underscore}"
diff --git a/app/views/help/_header.html.haml b/app/views/help/_header.html.haml
new file mode 100644
index 0000000..78fd24c
--- /dev/null
+++ b/app/views/help/_header.html.haml
@@ -0,0 +1,5 @@
+.navBar.navBar--secondary
+ %ul
+ %li.navBar__item= link_to "Sending E-Mail", [organization, @server, :help_outgoing], :class => ['navBar__link', active_nav == :outgoing ? 'is-active' : '']
+ %li.navBar__item= link_to "Receiving E-Mail", [organization, @server, :help_incoming], :class => ['navBar__link', active_nav == :incoming ? 'is-active' : '']
+
diff --git a/app/views/help/incoming.html.haml b/app/views/help/incoming.html.haml
new file mode 100644
index 0000000..351ef4d
--- /dev/null
+++ b/app/views/help/incoming.html.haml
@@ -0,0 +1,41 @@
+- page_title << @server.name
+- page_title << "Help"
+- page_title << "Receiving E-Mail"
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :help
+= render 'header', :active_nav => :incoming
+
+.pageContent.pageContent--compact
+ %h1.pageContent__title Receiving e-mail
+ %h2.pageContent__intro.u-margin
+ This system can handle your incoming e-mail by accepting it from other mail servers and
+ sending it on to your own applications using HTTP or to forward it to other SMTP servers.
+ %p.u-margin.pageContent__helpLink= link_to "Read more about sending e-mails", [organization, @server, :help_outgoing]
+ .u-margin
+ %h2.pageContent__subTitle Forwarding e-mails
+ %p.pageContent__text
+ If you already have a incoming mail server for your domain, you may find the quickest
+ way to get up and running is to simply forward e-mail from that server.
+ You don't need to make any changes to your DNS to do this.
+ %p.pageContent__text
+ Just #{link_to "create an incoming route", [organization, @server, :routes], :class => "u-link"}
+ for the address you want to receive messages for and then you'll be provided with
+ an e-mail address that messages can be forward to. Any message that is received to
+ this address will be treated as if it had been sent directly to the address on the route.
+ %p.pageContent__text
+ The address to forward mail to can be found by clicking on the route and copying the
+ field marked Address from the form.
+
+ .u-margin
+ %h2.pageContent__subTitle Setting your MX records
+ %p.pageContent__text
+ If you don't already have a mail server on your domain, you can simply set your
+ MX records to point to this system. The MX records are shown
+ below and you should add these both as priority 10 in your DNS configuration. Once
+ these have been added successfully they will show with a green tick on your domain list.
+ %dl.pageContent__definitions
+ %dt MX Records
+ %dd
+ - for mx in Postal.config.dns.mx_records
+ %p.pageContent__definitionCode= mx
+
diff --git a/app/views/help/outgoing.html.haml b/app/views/help/outgoing.html.haml
new file mode 100644
index 0000000..87fdef3
--- /dev/null
+++ b/app/views/help/outgoing.html.haml
@@ -0,0 +1,60 @@
+- page_title << @server.name
+- page_title << "Help"
+- page_title << "Sending E-Mail"
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :help
+= render 'header', :active_nav => :outgoing
+.pageContent.pageContent--compact
+ %h1.pageContent__title Sending e-mail
+ %h2.pageContent__intro.u-margin
+ There are a couple of different ways you send outgoing mail through a
+ mail server. These methods are shown below:
+ %p.u-margin.pageContent__helpLink= link_to "Read more about receiving e-mails", [organization, @server, :help_incoming]
+ .u-margin
+ %h2.pageContent__subTitle Important notes
+ %ul.pageContent__list
+ %li
+ E-mails can only be sent from addresses with domains that you have added to mail server or the server's organization.
+ Mail servers can be enabled to send mail from any domain by the administrator.
+ %li
+ If a message cannot be delivered, the system will not send you a bounce message but dispatch a webhook (if you set one up).
+ If a message delivery fails but can be retried, the system will try #{QueuedMessage::MAX_ATTEMPTS} times to deliver it before giving up.
+ .u-margin
+ %h2.pageContent__subTitle Sending using SMTP
+ %p.pageContent__text
+ These instructions explain how to send messages using the SMTP server.
+
+ %dl.pageContent__definitions
+ %dt SMTP Server Address
+ %dd
+ %p.pageContent__definitionCode= Postal.config.dns.smtp_server_hostname
+ %dt Port
+ %dd
+ %p.pageContent__definitionCode 25 or 2525
+ %p.pageContent__definitionText
+ The SMTP service supports STARTTLS if you wish to send messages securely. Be aware that security
+ cannot guaranteed all the way to their final destination.
+
+ %dt Username
+ %dd
+ %p.pageContent__definitionCode= @server.full_permalink
+ %dt Password
+ %dd
+ - if @credentials['SMTP'].present?
+ %p.pageContent__definitionCode
+ = @credentials['SMTP'].first.key
+ %p.pageContent__definitionText= link_to "Create more credentials", [organization, @server, :credentials], :class => "u-link"
+ - else
+ %p.warningBox
+ %b No SMTP credentials created for this server yet.
+ A password can be generated from the #{link_to 'credentials', [:new, organization, @server, :credential], :class => "u-link"}
+ page. Just create a credential with the SMTP type and add a name which suits the place you'll be using the credentials.
+
+ %dt Authentication Methods
+ %dd
+ %p.pageContent__definitionCode PLAIN, LOGIN or CRAM-MD5
+
+ .u-margin
+ %h2.pageContent__subTitle Sending over HTTP using our API
+ %p.pageContent__text
+ For full information about how to use our HTTP API, please #{link_to 'see the documentation', 'https://gopostal.io/~/http-sending', :class => "u-link"}.
diff --git a/app/views/http_endpoints/_form.html.haml b/app/views/http_endpoints/_form.html.haml
new file mode 100644
index 0000000..3d3ee23
--- /dev/null
+++ b/app/views/http_endpoints/_form.html.haml
@@ -0,0 +1,59 @@
+= form_for [organization, @server, @http_endpoint], :remote => true do |f|
+ = f.error_messages
+ %fieldset.fieldSet
+ .fieldSet__field
+ = f.label :name, :class => 'fieldSet__label'
+ .fieldSet__input= f.text_field :name, :autofocus => true, :class => 'input input--text'
+ .fieldSet__field
+ = f.label :url, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.text_field :url, :class => 'input input--text'
+ %p.fieldSet__text
+ Enter the full URL that we should POST your messages to. We recommend using https URLs here to
+ ensure your data remains secure in transit.
+ .fieldSet__field
+ = f.label :encoding, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.select :encoding, HTTPEndpoint::ENCODINGS.map { |e| [t("http_endpoint_encodings.#{e.underscore}"), e] }, {}, :class => 'input input--select'
+ %p.fieldSet__text
+ You can choose how the data will be delivered to your server. We recommend receiving data as JSON which will be
+ posted to your endpoint with an application/json content type. If you choose to use form data, you'll be able
+ to read parameters as normal without parsing any JSON.
+
+ .fieldSet__field
+ = f.label :format, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.select :format, HTTPEndpoint::FORMATS.map { |e| [t("http_endpoint_formats.#{e.underscore}"), e] }, {}, :class => 'input input--select'
+ %p.fieldSet__text
+ You can choose whether to receive the full raw message or whether you'd prefer to receive a individual properties
+ for a message individually.
+ .fieldSet__field
+ = f.label :strip_replies, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.select :strip_replies, [["Send the full message as received", false], ["Try to seperate replies/signatures from plain body", true]], {}, :class => 'input input--select'
+ %p.fieldSet__text
+ If enabled, we'll try to remove the replies/signatures from the plain body and send them seperately to the rest of the body.
+ This is useful if you just want to see the latest message in a thread.
+ .fieldSet__field
+ = f.label :include_attachments, "Attachments", :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.select :include_attachments, [["Include attachment data", true], ["Don't include attachment data", false]], {}, :class => 'input input--select'
+ %p.fieldSet__text
+ You can choose whether or not attachment data will be delivered to your app. This only applies when the message is delivered
+ as a hash (rather than the raw message - these will always have attachment data within).
+ .fieldSet__field
+ = f.label :timeout, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.text_field :timeout, :class => 'input input--text', :placeholder => "Default: 5"
+ %p.fieldSet__text
+ This is how long (in seconds) we should wait for your server to respond before giving up and trying again later. By default this is 5
+ seconds. The maximum value is 60 seconds.
+
+ .fieldSetSubmit.buttonSet
+ = f.submit @http_endpoint.new_record? ? "Create HTTP endpoint" : "Save HTTP endpoint", :class => 'button button--positive js-form-submit'
+ .fieldSetSubmit__delete
+ - if f.object.persisted?
+ = link_to "Delete HTTP endpoint", [organization, @server, @http_endpoint], :remote => true, :class => 'button button--danger', :method => :delete, :data => {:confirm => "Are you sure you wish to delete this HTTP endpoint?\n\r#{pluralize @http_endpoint.routes.size, 'route'} that uses this endpoint will also be deleted."}
+
+ = hidden_field_tag 'return_to', params[:return_to]
+ = hidden_field_tag 'return_notice', params[:return_notice]
diff --git a/app/views/http_endpoints/edit.html.haml b/app/views/http_endpoints/edit.html.haml
new file mode 100644
index 0000000..6d2a8af
--- /dev/null
+++ b/app/views/http_endpoints/edit.html.haml
@@ -0,0 +1,11 @@
+- page_title << @server.name
+- page_title << "Routing"
+- page_title << "HTTP Endpoints"
+- page_title << "Edit"
+
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :routing
+= render 'routes/header', :active_nav => :http_endpoints
+.pageContent.pageContent--compact
+ = render 'form'
+
diff --git a/app/views/http_endpoints/index.html.haml b/app/views/http_endpoints/index.html.haml
new file mode 100644
index 0000000..406939d
--- /dev/null
+++ b/app/views/http_endpoints/index.html.haml
@@ -0,0 +1,38 @@
+- page_title << @server.name
+- page_title << "Routing"
+- page_title << "HTTP Endpoints"
+
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :routing
+= render 'routes/header', :active_nav => :http_endpoints
+.pageContent.pageContent--compact
+
+ - if @http_endpoints.empty?
+ .noData.noData--cock.noData--clean
+ %h2.noData__title Oh cock! There aren't any HTTP endpoints yet.
+ %p.noData__text
+ HTTP endpoints are essentially URLs that you'd like incoming e-mails
+ to be delivered to. Once you've added some endpoints, you can route messages
+ to them by creating #{link_to 'routes', [organization, @server, :routes], :class => 'u-link'}.
+ %p.noData__button
+ = link_to "Add your first HTTP endpoint", [:new, organization, @server, :http_endpoint], :class => 'button button--positive'
+
+ - else
+
+ %ul.endpointList.u-margin
+ - for endpoint in @http_endpoints
+ %li.endpointList__item
+ = link_to [:edit, organization, @server, endpoint], :class => 'endpointList__link' do
+ .endpointList__main
+ %p.endpointList__name= endpoint.name
+ %p.endpointList__url= endpoint.url
+ %ul.endpointList__details
+ %li.endpointList__detailItem= t("http_endpoint_encodings.#{endpoint.encoding.underscore}")
+ %li.endpointList__detailItem= t("http_endpoint_formats.#{endpoint.format.underscore}")
+ %li.endpointList__detailItem
+ - if endpoint.last_used_at
+ Last used #{distance_of_time_in_words_to_now endpoint.last_used_at} ago
+ - else
+ Not used yet
+
+ %p.u-center= link_to "Add another HTTP endpoint", [:new, organization, @server, :http_endpoint], :class => 'button button--positive'
diff --git a/app/views/http_endpoints/new.html.haml b/app/views/http_endpoints/new.html.haml
new file mode 100644
index 0000000..501317f
--- /dev/null
+++ b/app/views/http_endpoints/new.html.haml
@@ -0,0 +1,11 @@
+- page_title << @server.name
+- page_title << "Routing"
+- page_title << "HTTP Endpoints"
+- page_title << "New"
+
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :routing
+= render 'routes/header', :active_nav => :http_endpoints
+.pageContent.pageContent--compact
+ = render 'form'
+
diff --git a/app/views/ip_pool_rules/_form.html.haml b/app/views/ip_pool_rules/_form.html.haml
new file mode 100644
index 0000000..eaff492
--- /dev/null
+++ b/app/views/ip_pool_rules/_form.html.haml
@@ -0,0 +1,35 @@
+.pageContent.pageContent--compact
+ = form_for [organization, @server, @ip_pool_rule], :remote => true do |f|
+ = f.error_messages
+ %fieldset.fieldSet
+ %h2.fieldSet__title.fieldSet__title--noMargin Rule match conditions
+ .fieldSet__field
+ = f.label :to_text, "To Addresses", :class => 'fieldSet__label'
+ .fieldSet__input
+ ~ f.text_area :to_text, :autofocus => true, :class => 'input input--text input--smallArea'
+ %p.fieldSet__text
+ This is a list of addresses or domains which should be matched. This
+ applies to e-mail address of the recipient of a message.
+
+ .fieldSet__field
+ = f.label :from_text, "From Addresses", :class => 'fieldSet__label'
+ .fieldSet__input
+ ~ f.text_area :from_text, :class => 'input input--text input--smallArea'
+ %p.fieldSet__text
+ This is a list of addresses or domains which should be matched. This
+ applies to value From in the From header of the message
+ that is being delivered.
+ %fieldset.fieldSet
+ %h2.fieldSet__title Selected IP Pool
+ .fieldSet__field
+ = f.label :ip_pool_id, "IP Pool", :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.collection_select :ip_pool_id, organization.ip_pools.includes(:ip_addresses).order("`default` desc, name asc"), :id, :description, {}, :class => 'input input--select'
+ %p.fieldSet__text
+ This is the IP pool that this message should be delivered from.
+
+ .fieldSetSubmit
+ = f.submit "Save Rule", :class => "button button--positive js-form-submit"
+ .fieldSetSubmit__delete
+ - if f.object.persisted?
+ = link_to "Delete Rule", [organization, @server, @ip_pool_rule], :remote => true, :class => 'button button--danger', :method => :delete, :data => {:confirm => "Are you sure you wish to delete this rule?"}
diff --git a/app/views/ip_pool_rules/edit.html.haml b/app/views/ip_pool_rules/edit.html.haml
new file mode 100644
index 0000000..6b2f715
--- /dev/null
+++ b/app/views/ip_pool_rules/edit.html.haml
@@ -0,0 +1,20 @@
+- if @server
+ - page_title << @server.name
+- page_title << "Edit IP Pool Rule"
+
+- if @server
+ = render 'servers/sidebar', :active_server => @server
+ = render 'servers/header', :active_nav => :settings
+ = render 'servers/settings_header', :active_nav => :ip_pool_rules
+- else
+ .pageHeader
+ %h1.pageHeader__title
+ %span.pageHeader__titlePrevious
+ = @organization.name
+ →
+ IP Pool Rules
+ →
+ Edit rule
+ = render 'organizations/nav', :active_nav => :ips
+ = render 'ip_pools/nav', :active_nav => :rules
+= render 'form'
diff --git a/app/views/ip_pool_rules/index.html.haml b/app/views/ip_pool_rules/index.html.haml
new file mode 100644
index 0000000..1e2ab1d
--- /dev/null
+++ b/app/views/ip_pool_rules/index.html.haml
@@ -0,0 +1,65 @@
+- if @server
+ - page_title << @server.name
+ - page_title << "IP Pool Rules"
+- else
+ - page_title << "IPs"
+ - page_title << "Rules"
+
+- if @server
+ = render 'servers/sidebar', :active_server => @server
+ = render 'servers/header', :active_nav => :settings
+ = render 'servers/settings_header', :active_nav => :ip_pool_rules
+- else
+ .pageHeader
+ %h1.pageHeader__title
+ %span.pageHeader__titlePrevious
+ = @organization.name
+ →
+ IP Pool Rules
+ = render 'organizations/nav', :active_nav => :ips
+ = render 'ip_pools/nav', :active_nav => :rules
+
+.pageContent.pageContent--compact
+ - if @ip_pool_rules.empty?
+ .noData.noData--panda.noData--clean
+ - if @server.nil?
+ %h2.noData__title No global rules have been configured yet.
+ %p.noData__text
+ You can use IP pool rules to configure which IP addresses to use based on the
+ message that are passing through Postal. You can add rules globally or on a
+ per-server basis.
+ %p.noData__button= link_to "Add a global rule", [:new, organization, @server, :ip_pool_rule], :class => "button button--positive"
+ - else
+ %h2.noData__title No IP rules have been configured for this server yet.
+ %p.noData__text
+ You can use IP pool rules to configure which IP addresses to use based on the
+ message that are passing through Postal. You can add rules globally or on a
+ per-server basis.
+ %p.noData__button= link_to "Add a server rule", [:new, organization, @server, :ip_pool_rule], :class => "button button--positive"
+ -
+ - else
+ .ipPoolRuleList.u-margin
+ - for ip_pool_rule in @ip_pool_rules
+ .ipPoolRuleList__item
+ = link_to [:edit, organization, @server, ip_pool_rule], :class => 'ipPoolRuleList__link' do
+ - if ip_pool_rule.to.present?
+ %dl.ipPoolRuleList__condition
+ %dt Any messages sent to:
+ %dd
+ %ul
+ - for a in ip_pool_rule.to
+ %li= a
+ - if ip_pool_rule.from.present?
+ %dl.ipPoolRuleList__condition
+ %dt Any message sent from:
+ %dd
+ %ul
+ - for a in ip_pool_rule.from
+ %li= a
+
+ %dl.ipPoolRuleList__condition
+ %dt Will be sent using:
+ %dd= ip_pool_rule.ip_pool.description
+ - if @server
+ %p.ipPoolRuleListDefault.u-margin All mail that doesn't match a rule above will be sent using #{@server.ip_pool.description}.
+ %p.u-center= link_to "Add another rule", [:new, organization, @server, :ip_pool_rule], :class => "button button--positive"
diff --git a/app/views/ip_pool_rules/new.html.haml b/app/views/ip_pool_rules/new.html.haml
new file mode 100644
index 0000000..203a64a
--- /dev/null
+++ b/app/views/ip_pool_rules/new.html.haml
@@ -0,0 +1,21 @@
+- if @server
+ - page_title << @server.name
+- page_title << "Add IP Pool Rule"
+
+- if @server
+ = render 'servers/sidebar', :active_server => @server
+ = render 'servers/header', :active_nav => :settings
+ = render 'servers/settings_header', :active_nav => :ip_pool_rules
+- else
+ .pageHeader
+ %h1.pageHeader__title
+ %span.pageHeader__titlePrevious
+ = @organization.name
+ →
+ IP Pool Rules
+ →
+ Add new rule
+ = render 'organizations/nav', :active_nav => :ips
+ = render 'ip_pools/nav', :active_nav => :rules
+
+= render 'form'
diff --git a/app/views/ip_pools/_nav.html.haml b/app/views/ip_pools/_nav.html.haml
new file mode 100644
index 0000000..cab2752
--- /dev/null
+++ b/app/views/ip_pools/_nav.html.haml
@@ -0,0 +1,4 @@
+.navBar.navBar--secondary
+ %ul
+ %li.navBar__item= link_to "Dedicated IPs", organization_ip_pools_path(organization), :class => ['navBar__link', active_nav == :ips ? 'is-active' : '']
+ %li.navBar__item= link_to "Rules", organization_ip_pool_rules_path(organization), :class => ['navBar__link', active_nav == :rules ? 'is-active' : '']
diff --git a/app/views/ip_pools/index.html.haml b/app/views/ip_pools/index.html.haml
new file mode 100644
index 0000000..d221baa
--- /dev/null
+++ b/app/views/ip_pools/index.html.haml
@@ -0,0 +1,38 @@
+- page_title << "IPs"
+- page_title << "Rules"
+
+.pageHeader
+ %h1.pageHeader__title
+ %span.pageHeader__titlePrevious
+ = @organization.name
+ →
+ Dedicated IPs
+
+= render 'organizations/nav', :active_nav => :ips
+= render 'nav', :active_nav => :ips
+.pageContent.pageContent--compact
+ - if @ip_pools.empty?
+ .noData.noData--koala.noData--clean
+ - if @server.nil?
+ %h2.noData__title You don't have any dedicated IP addresses.
+ %p.noData__text
+ Once you've been assigned dedicated IP addresses they will appear here. You can then use them in rules and
+ for servers. To request dedicated IP addresses please contact our team.
+ - else
+ .ipList
+ - for ip_pool in @ip_pools
+ .ipList__item
+ %p.ipList__name= ip_pool.name
+ %ul.ipList__addressList
+ %li.ipList__address.ipList__address--header
+ %p.ipList__ipv4 IPv4 Address
+ %p.ipList__ipv6 IPv6 Address
+ %p.ipList__hostname Hostname
+
+ - for address in ip_pool.ip_addresses
+ %li.ipList__address
+ %p.ipList__ipv4= address.ipv4
+ %p.ipList__ipv6= address.ipv6
+ %p.ipList__hostname= address.hostname
+
+
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
new file mode 100644
index 0000000..bc6ceb4
--- /dev/null
+++ b/app/views/layouts/application.html.haml
@@ -0,0 +1,52 @@
+!!!
+%html.main
+ %head
+ %title #{page_title.reverse.join(' - ')}
+ = csrf_meta_tags
+ = stylesheet_link_tag 'application/application', 'data-turbolinks-track' => 'reload'
+ = javascript_include_tag 'application/application', 'data-turbolinks-track' => 'reload'
+ %link{:href => asset_path('favicon.png'), :rel => 'shortcut icon'}
+
+ = yield :head
+ %body
+ = display_flash
+ %header.siteHeader{'data-turbolinks-permanent' => true}
+ - if flash[:remember_login] && !auth_session.persistent?
+ .siteHeader__remember.js-remember
+ .siteHeader__rememberText
+ %p.siteHeader__rememberTextTitle Would you like to stay logged in?
+ %p This will keep you logged in in this browser for 2 months.
+ .siteHeader__rememberButtons.buttonSet
+ = link_to "Remember me", '#', :class => 'button button--positive button--small', :data => {:remember => 'yes'}
+ = link_to "Close", '#', :class => 'button button--dark button--small', :data => {:remember => 'no'}
+
+ .siteHeader__inside
+ .siteHeader__logo= link_to "Postal", root_path
+ %p.siteHeader__version The open source e-mail platform
+ %ul.siteHeader__nav
+ - if defined?(organization) && organization
+ %li.siteHeader__navItem
+ %li.siteHeader__navItem.siteHeader__navItem--organization
+ = link_to organization.name, organization_root_path(organization), :class => 'siteHeader__navLinkWithMenu'
+ %ul.siteHeader__subMenu
+ %li.siteHeader__subMenuItem.siteHeader__subMenuItem--header= link_to organization.name, organization_settings_path(organization)
+ - if organization.admin?(current_user)
+ %li.siteHeader__subMenuItem= link_to "Organization Settings", organization_settings_path(organization), :class => 'siteHeader__subMenuLink'
+ %li.siteHeader__subMenuItem= link_to "Manage Users", organization_users_path(organization), :class => 'siteHeader__subMenuLink'
+
+ %li.siteHeader__subMenuItem.siteHeader__subMenuItem--div= link_to "Create new organization", :new_organization, :class => 'siteHeader__subMenuLink'
+ - if current_user.organizations.present.count > 1
+ %li.siteHeader__subMenuItem= link_to "Switch organization", root_path, :class => 'siteHeader__subMenuLink'
+ %li.siteHeader__navItem.siteHeader__navItem--user= current_user.name
+ - if current_user.admin?
+ %li.siteHeader__navItem= link_to "Admin", admin_root_path, :class => 'sideHeader__navItemLink'
+ %li.siteHeader__navItem= link_to "My Settings", settings_path, :class => 'sideHeader__navItemLink'
+ %li.siteHeader__navItem= link_to "Logout", logout_path, :method => :delete, :class => 'sideHeader__navItemLink'
+
+ .siteContent
+ - if content_for?(:sidebar)
+ %nav.sidebar
+ = content_for :sidebar
+
+ %section.siteContent__main
+ = yield
diff --git a/app/views/layouts/sub.html.haml b/app/views/layouts/sub.html.haml
new file mode 100644
index 0000000..ef6808b
--- /dev/null
+++ b/app/views/layouts/sub.html.haml
@@ -0,0 +1,13 @@
+!!!
+%html.subPage
+ %head
+ %title #{page_title.reverse.join(' - ')}
+ = csrf_meta_tags
+ = stylesheet_link_tag 'application/application', 'data-turbolinks-track' => 'reload'
+ = javascript_include_tag 'application/application', 'data-turbolinks-track' => 'reload'
+ %link{:href => asset_path('favicon.png'), :rel => 'shortcut icon'}
+
+ %body
+ .subPageBox{:class => @wide ? "subPageBox--wide" : ''}
+ = yield
+
diff --git a/app/views/messages/_deliveries.html.haml b/app/views/messages/_deliveries.html.haml
new file mode 100644
index 0000000..f555157
--- /dev/null
+++ b/app/views/messages/_deliveries.html.haml
@@ -0,0 +1,48 @@
+%ul.deliveryList
+ - if message.queued_message && message.queued_message.locked?
+ %li.deliveryList__item.deliveryList__item--header
+ %p Message is currently being processed.
+ - elsif message.queued_message && message.queued_message.retry_after
+ %li.deliveryList__item.deliveryList__item--header
+ %p This message will be retried automatically in #{distance_of_time_in_words_to_now message.queued_message.retry_after}.
+ %p= link_to "Retry delivery now", retry_organization_server_message_path(organization, @server, message.id), :class => "button button--small", :remote => true, :method => :post
+ - elsif message.held?
+ %li.deliveryList__item.deliveryList__item--header
+ %p
+ This message has been held. By releasing the message, we will allow it to continue on its way to its destination.
+ - if @message.hold_expiry
+ It will be held until #{@message.hold_expiry.to_s(:long)}.
+ %p.buttonSet
+ = link_to "Release message", retry_organization_server_message_path(organization, @server, message.id), :class => "button button--small", :remote => true, :method => :post
+ = link_to "Cancel hold", cancel_hold_organization_server_message_path(organization, @server, message.id), :class => "button button--small button--danger", :remote => true, :method => :post
+ - elsif @server.mode == 'Development'
+ %li.deliveryList__item.deliveryList__item--header
+ %p This server is in development mode so this message can be redelivered as if it had just been received.
+ %p= link_to "Redeliver message", retry_organization_server_message_path(organization, @server, message.id), :class => "button button--small", :remote => true, :method => :post
+
+ - if message.deliveries.empty?
+ %li.deliveryList__item
+ .noData.noData--fox.noData--clean
+ %h2.noData__text No delivery attempts yet.
+ - else
+ - for delivery in message.deliveries.reverse
+ %li.deliveryList__item
+ .deliveryList__top
+ .deliveryList__time
+ = delivery.timestamp.to_s(:long)
+ .deliveryList__status
+ - if delivery.sent_with_ssl == 1
+ = image_tag 'icons/lock.svg', :class => 'deliveryList__secure'
+ %span.label.label--large{:class => "label--messageStatus-#{delivery.status.underscore}"}= delivery.status.underscore.humanize
+ - if delivery.details
+ %p.deliveryList__error= format_delivery_details(@server, delivery.details)
+ - if delivery.log_id || delivery.output
+ = link_to "Show technical details", '#', :class => 'js-toggle js-tech-link deliveryList__techLink', :data => {:element => '.js-tech-link, .js-tech-output'}
+ .deliveryList__error.deliveryList__error--output.js-tech-output.is-hidden
+ %p.deliveryList__error--output-text= delivery.output
+ - if delivery.time
+ %p.deliveryList__error--output-ref Time: #{delivery.time}s
+ - if delivery.log_id
+ %p.deliveryList__error--output-ref Support Ref: #{delivery.log_id}
+- if message.queued_message && !message.queued_message.locked?
+ %p.deliveryList-removeLink= link_to "Remove from queue", remove_from_queue_organization_server_message_path(organization, @server, message.id), :method => :delete, :remote => true, :data => {:disable_with => "Removing...", :confirm => "Are you sure you wish to remove this message from the queue?"}, :class => "u-link"
diff --git a/app/views/messages/_header.html.haml b/app/views/messages/_header.html.haml
new file mode 100644
index 0000000..61d9ae6
--- /dev/null
+++ b/app/views/messages/_header.html.haml
@@ -0,0 +1,8 @@
+.navBar.navBar--secondary
+ %ul
+ %li.navBar__item= link_to "Outgoing Messages", [:outgoing, organization, @server, :messages], :class => ['navBar__link', active_nav == :outgoing ? 'is-active' : '']
+ %li.navBar__item= link_to "Incoming Messages", [:incoming, organization, @server, :messages], :class => ['navBar__link', active_nav == :incoming ? 'is-active' : '']
+ %li.navBar__item= link_to "Queue", [:queue, organization, @server], :class => ['navBar__link', active_nav == :queue ? 'is-active' : '']
+ %li.navBar__item= link_to "Held", [:held, organization, @server, :messages], :class => ['navBar__link', active_nav == :held ? 'is-active' : '']
+ %li.navBar__item= link_to "Send Message", [:new, organization, @server, :message], :class => ['navBar__link', active_nav == :new ? 'is-active' : '']
+ %li.navBar__item= link_to "Suppressions", [:suppressions, organization, @server, :messages], :class => ['navBar__link', active_nav == :suppressions ? 'is-active' : '']
diff --git a/app/views/messages/_index.html.haml b/app/views/messages/_index.html.haml
new file mode 100644
index 0000000..447bc9d
--- /dev/null
+++ b/app/views/messages/_index.html.haml
@@ -0,0 +1,13 @@
+.pageContent.js-ajax-region
+ - if @searchable
+ = render 'search'
+
+ - if @messages[:records].empty?
+ .noData.noData--clean.noData--koala
+ %h2.noData__title No koalified messages found matching your filter.
+ %p.noData__text
+ There were no messages which matched the query that you entered. Sorry about that.
+ - else
+ = render 'list', :messages => @messages[:records]
+ = render 'shared/message_db_pagination', :data => @messages, :name => "message"
+
diff --git a/app/views/messages/_list.html.haml b/app/views/messages/_list.html.haml
new file mode 100644
index 0000000..79e5fdb
--- /dev/null
+++ b/app/views/messages/_list.html.haml
@@ -0,0 +1,24 @@
+%ul.messageList
+ - for message in messages
+ - if message.is_a?(QueuedMessage)
+ - message = message.message
+ %li.messageList__message
+ = link_to organization_server_message_path(organization, @server, message.id), :class => 'messageList__link' do
+ .messageList__details{:class => 'messageList__details--' + message.scope}
+ %p.messageList__subject= message.subject || "No subject"
+ %dl.messageList__addresses
+ %dt To
+ %dd
+ - if message.rcpt_to_return_path?
+ %span.returnPathTag Return Path
+ - else
+ = message.rcpt_to || "none"
+ %dt From
+ %dd= message.mail_from || "none"
+
+ .messageList__meta
+ %p.messageList__timestamp= message.timestamp.in_time_zone.to_s(:long)
+ %p.messageList__status
+ - if message.read?
+ %span.label.label--purple Opened
+ %span.label{:class => "label--messageStatus-#{message.status.underscore}"}= message.status.underscore.humanize
diff --git a/app/views/messages/_message_header.html.haml b/app/views/messages/_message_header.html.haml
new file mode 100644
index 0000000..41c537c
--- /dev/null
+++ b/app/views/messages/_message_header.html.haml
@@ -0,0 +1,38 @@
+.messageHeader
+ .messageHeader__header{:class => "messageHeader__header--#{@message.scope}"}
+ %p.messageHeader__status
+ %span.label{:class => "label--messageStatus-#{@message.status.underscore}"}= @message.status.underscore.humanize
+ %h2.messageHeader__subject
+ = @message.subject || "No subject"
+
+ .messageHeader__basicProperties
+ %dl
+ %dt From
+ %dd
+ - if @message.mail_from
+ = link_to @message.mail_from || "[blank]", send("#{@message.scope}_organization_server_messages_path", organization, @server, :query => "from: #{@message.mail_from}"), :class => 'u-link'
+ - else
+ None
+
+ %dl
+ %dt To
+ %dd
+ - if @message.rcpt_to_return_path?
+ %span.returnPathTag.returnPathTag--inMessageHeader= link_to "Return Path", send("#{@message.scope}_organization_server_messages_path", organization, @server, :query => "to: #{@message.rcpt_to}"), :class => 'u-link'
+ - else
+ = link_to @message.rcpt_to || "[blank]", send("#{@message.scope}_organization_server_messages_path", organization, @server, :query => "to: #{@message.rcpt_to}"), :class => 'u-link'
+ %dl
+ %dt Received
+ %dd= @message.timestamp.in_time_zone.to_s(:long)
+
+.navBar.navBar--tertiary
+ %ul
+ %li.navBar__item= link_to "Properties", organization_server_message_path(organization, @server, @message.id), :class => ['navBar__link', active_nav == :properties ? 'is-active' : '']
+ %li.navBar__item= link_to "Activity", activity_organization_server_message_path(organization, @server, @message.id), :class => ['navBar__link', active_nav == :activity ? 'is-active' : '']
+ %li.navBar__item= link_to "Headers", headers_organization_server_message_path(organization, @server, @message.id), :class => ['navBar__link', active_nav == :headers ? 'is-active' : '']
+ %li.navBar__item= link_to "Spam Checks", spam_checks_organization_server_message_path(organization, @server, @message.id), :class => ['navBar__link', active_nav == :spam_checks ? 'is-active' : '']
+ %li.navBar__item= link_to "Plain Text", plain_organization_server_message_path(organization, @server, @message.id), :class => ['navBar__link', active_nav == :plain ? 'is-active' : '']
+ %li.navBar__item= link_to "HTML", html_organization_server_message_path(organization, @server, @message.id), :class => ['navBar__link', active_nav == :html ? 'is-active' : '']
+ %li.navBar__item= link_to "Attachments", attachments_organization_server_message_path(organization, @server, @message.id), :class => ['navBar__link', active_nav == :attachments ? 'is-active' : '']
+ - if @message.raw_message?
+ %li.navBar__item= link_to "Download", download_organization_server_message_path(organization, @server, @message.id), :data => {:turbolinks => 'false'}, :class =>'navBar__link'
diff --git a/app/views/messages/_search.html.haml b/app/views/messages/_search.html.haml
new file mode 100644
index 0000000..415612e
--- /dev/null
+++ b/app/views/messages/_search.html.haml
@@ -0,0 +1,41 @@
+= form_tag request.fullpath, :method => :get, :remote => true, :class => 'messageSearch', :enforce_utf8 => false do
+ %p
+ = link_to "Need help with filtering?", '#', :class => 'messageSearch__help js-toggle-helpbox'
+ = text_field_tag 'query', @query, :class => 'messageSearch__input js-focus-on-f js-form-submit', :placeholder => "Filter messages...", :data => {:disable_with => 'Searching...'}
+
+ .messageSearch__helpBox.is-hidden.js-helpbox
+ .messageSearch__left
+ %h3.messageSearch__helpBoxTitle
+ Filtering your messages
+ %p.messageSearch__helpBoxText
+ You can filter your messages on a number of attributes. At present, it is not possible to
+ search the content of your messages. To filter though, you can insert any of the strings
+ as shown opposite into the box above and press enter.
+ .messageSearch__right
+ %dl.messageSearch__definition
+ %dt to: rachel@example.com
+ %dd Returns all mail addressed to the address provided.
+ %dl.messageSearch__definition
+ %dt from: tom@example.com
+ %dd Returns all mail sent from to the address provided.
+ %dl.messageSearch__definition
+ %dt status: pending
+ %dd Returns all messages with the status provided. The suitable statuses are: pending, sent, held, softfail, hardfail and bounced.
+ %dl.messageSearch__definition
+ %dt before: yyyy-mm-dd hh:mm
+ %dd Returns any message received before the given timestamp.
+ %dl.messageSearch__definition
+ %dt after: yyyy-mm-dd hh:mm
+ %dd Returns any message received after the given timestamp.
+ %dl.messageSearch__definition
+ %dt msgid: 57f3a85b35545@server01.mail
+ %dd Returns any message with the given Message-ID header.
+ %dl.messageSearch__definition
+ %dt tag: password-reset
+ %dd Returns any message tagged with the tag provided.
+ %dl.messageSearch__definition
+ %dt spam: yes
+ %dd By default, spam is not shown in results. To show spam instead of non-spam, just add this to the query.
+ %dl.messageSearch__definition
+ %dt order: oldest-first
+ %dd By default, newest messages are shown first. To show oldest messages first, you can add this.
diff --git a/app/views/messages/activity.html.haml b/app/views/messages/activity.html.haml
new file mode 100644
index 0000000..64910a0
--- /dev/null
+++ b/app/views/messages/activity.html.haml
@@ -0,0 +1,45 @@
+- page_title << @server.name
+- page_title << "Messages"
+- page_title << "Message ##{@message.id}"
+- page_title << "Activity"
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :messages
+= render 'header', :active_nav => @message.scope.to_sym
+= render 'message_header', :active_nav => :activity
+.pageContent.pageContent--compact
+ %ul.messageActivity
+ - for entry in @entries.reverse
+ - if entry.is_a?(Postal::MessageDB::Delivery)
+ %li.messageActivity__event
+ %p.messageActivity__timestamp= entry.timestamp.to_s(:long)
+ .messageActivity__details.messageActivity--detailsDelivery
+ %p.messageActivity__subject
+ =# entry.status.underscore.humanize
+ %span.label.label--large{:class => "label--messageStatus-#{entry.status.underscore}"}= entry.status.underscore.humanize
+
+ %p.messageActivity__extra= entry.details
+
+ - elsif entry.is_a?(Postal::MessageDB::Click)
+ %li.messageActivity__event
+ %p.messageActivity__timestamp= entry.timestamp.to_s(:long)
+ .messageActivity__details.messageActivity--detailsClick
+ %p.messageActivity__subject Click for #{entry.url}
+ %p.messageActivity__extra Clicked from #{entry.ip_address} (#{entry.user_agent})
+
+ - elsif entry.is_a?(Postal::MessageDB::Load)
+ %li.messageActivity__event
+ %p.messageActivity__timestamp= entry.timestamp.to_s(:long)
+ .messageActivity__details.messageActivity--detailsLoad
+ %p.messageActivity__subject Message Viewed
+ %p.messageActivity__extra Opened from #{entry.ip_address} (#{entry.user_agent})
+
+ %li.messageActivity__event
+ %p.messageActivity__timestamp= @message.timestamp.to_s(:long)
+ .messageActivity__details
+ %p.messageActivity__subject
+ Message received by Postal
+ %p.messageActivity__extra
+ - if @message.credential
+ Received using the #{@message.credential.name} #{@message.credential.type} credential.
+ - if @message.received_with_ssl == 1
+ Connection secured with SSL.
diff --git a/app/views/messages/attachments.html.haml b/app/views/messages/attachments.html.haml
new file mode 100644
index 0000000..f7de973
--- /dev/null
+++ b/app/views/messages/attachments.html.haml
@@ -0,0 +1,23 @@
+- page_title << @server.name
+- page_title << "Messages"
+- page_title << "Message ##{@message.id}"
+- page_title << "Attachments"
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :messages
+= render 'header', :active_nav => @message.scope.to_sym
+= render 'message_header', :active_nav => :attachments
+.pageContent.pageContent--compact
+ - if @message.attachments.empty?
+ .noData.noData--clean.noData--wolf
+ %h2.noData__title There are no attachments for this message.
+ %p.noData__text
+ This means that we no longer store the raw data for this e-mail
+ or the e-mail just didn't have any attached files.
+ - else
+ %ul.largeList
+ - @message.attachments.each_with_index do |attachment, i|
+ %li.largeList__item
+ = link_to attachment_organization_server_message_path(organization, @server, @message.id, :attachment => i), :class => 'largeList__link', :data => {:turbolinks => "false"} do
+ %p.largeList__rightLabel= number_to_human_size attachment.body.to_s.bytesize
+ %p= attachment.filename
+ %p.largeList__subText= attachment.mime_type
diff --git a/app/views/messages/headers.html.haml b/app/views/messages/headers.html.haml
new file mode 100644
index 0000000..58bd349
--- /dev/null
+++ b/app/views/messages/headers.html.haml
@@ -0,0 +1,25 @@
+- page_title << @server.name
+- page_title << "Messages"
+- page_title << "Message ##{@message.id}"
+- page_title << "Headers"
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :messages
+= render 'header', :active_nav => @message.scope.to_sym
+= render 'message_header', :active_nav => :headers
+
+- if @message.headers.empty?
+ .pageContent.pageContent--compact
+ .noData.noData--clean.noData--cat2
+ %h2.noData__title There are no headers for this message.
+ %p.noData__text
+ This means that we no longer store the raw data for this e-mail.
+
+- else
+ .pageContent
+ .headersList
+ - for key, values in @message.headers
+ - for value in values
+ %dl.headersList__item
+ %dt= key
+ %dd= value
+
diff --git a/app/views/messages/held.html.haml b/app/views/messages/held.html.haml
new file mode 100644
index 0000000..d973c82
--- /dev/null
+++ b/app/views/messages/held.html.haml
@@ -0,0 +1,16 @@
+- page_title << @server.name
+- page_title << "Messages"
+- page_title << "Held"
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :messages
+= render 'header', :active_nav => :held
+- if @messages.empty? && !@queried
+ .pageContent--compact
+ .noData.noData--deer.noData--clean
+ %h2.noData__title You haven't got any held messages.
+ %p.noData__text
+ You haven't sent any messages through this mail server yet. Not to worry though
+ they'll start appearing here as soon as you start sending them.
+- else
+ = render 'index'
+
diff --git a/app/views/messages/html.html.haml b/app/views/messages/html.html.haml
new file mode 100644
index 0000000..a3ccd88
--- /dev/null
+++ b/app/views/messages/html.html.haml
@@ -0,0 +1,17 @@
+- page_title << @server.name
+- page_title << "Messages"
+- page_title << "Message ##{@message.id}"
+- page_title << "HTML"
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :messages
+= render 'header', :active_nav => @message.scope.to_sym
+= render 'message_header', :active_nav => :html
+- if @message.html_body.blank?
+ .pageContent.pageContent--compact
+ .noData.noData--clean.noData--panda
+ %h2.noData__title There's no HTML body for this message.
+ %p.noData__text
+ This means that we no longer store the raw data for this e-mail
+ or the e-mail didn't include a HTML part.
+- else
+ %iframe{:width => "100%", :height => "100%", :src => html_raw_organization_server_message_path(organization, @server, @message.id)}
diff --git a/app/views/messages/incoming.html.haml b/app/views/messages/incoming.html.haml
new file mode 100644
index 0000000..3c60cb5
--- /dev/null
+++ b/app/views/messages/incoming.html.haml
@@ -0,0 +1,18 @@
+- page_title << @server.name
+- page_title << "Messages"
+- page_title << "Incoming"
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :messages
+= render 'header', :active_nav => :incoming
+- if @messages[:records].empty? && !@queried
+ .pageContent--compact
+ .noData.noData--deer.noData--clean
+ %h2.noData__title Oh deer! You haven't received any messages yet.
+ %p.noData__text
+ You haven't received any messages through this mail server yet. Not to worry though
+ they'll start appearing here as soon as you start receiving them.
+ %p.noData__button
+ = link_to "View spam messages", incoming_organization_server_messages_path(organization, @server, :query => "spam: yes"), :class => "button button--neutral"
+
+- else
+ = render 'index'
diff --git a/app/views/messages/new.html.haml b/app/views/messages/new.html.haml
new file mode 100644
index 0000000..13d02eb
--- /dev/null
+++ b/app/views/messages/new.html.haml
@@ -0,0 +1,49 @@
+- page_title << @server.name
+- page_title << "Messages"
+- page_title << "Send"
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :messages
+= render 'header', :active_nav => :new
+.pageContent.pageContent--compact
+ %p.pageContent__intro.u-margin
+ You can use this form to send a message through this mail server. This is useful
+ for testing and debugging purposes.
+ - if @message.is_a?(OutgoingMessagePrototype)
+ %p.pageContent__text.u-margin.newMessageType.newMessageType--outgoing
+ You are sending an outgoing message. This e-mail will be routed as if it was an e-mail sent from your mail server.
+ = link_to "Simulate an incoming e-mail instead?", {:direction => 'incoming'}, :class => 'u-link'
+ - else
+ %p.pageContent__text.u-margin.newMessageType.newMessageType--incoming
+ You are sending an incoming message. This e-mail will can only be sent to your routes and will behave as if it was received by your mail server.
+ = link_to "Simulate an outgoing e-mail instead?", {:direction => 'outgoing'}, :class => 'u-link'
+ = form_tag [organization, @server, :messages], :remote => true do
+ = hidden_field_tag 'direction', params[:direction]
+ .fieldSet
+ - if @message.is_a?(OutgoingMessagePrototype)
+ .fieldSet__field
+ = label_tag :message_from, "From ", :class => 'fieldSet__label'
+ .fieldSet__input
+ = text_field_tag "message[from]", @message.from, :autofocus => true, :class => 'input input--text'
+ %p.fieldSet__text
+ Enter the address that you wish to wish to send the message from. This must be
+ an address which exists at one of your verified domains.
+ .fieldSet__field
+ = label_tag :message_to, "To", :class => 'fieldSet__label'
+ .fieldSet__input= text_field_tag "message[to]", @message.to, :class => 'input input--text'
+ - else
+ .fieldSet__field
+ = label_tag :message_route_id, "Route", :class => 'fieldSet__label'
+ .fieldSet__input= text_field_tag "message[to]", @message.to, :class => 'input input--text'
+
+ .fieldSet__field
+ = label_tag :message_from, "From", :class => 'fieldSet__label'
+ .fieldSet__input= text_field_tag "message[from]", @message.from, :class => 'input input--text'
+ .fieldSet__field
+ = label_tag :message_subject, "Subject", :class => 'fieldSet__label'
+ .fieldSet__input= text_field_tag "message[subject]", @message.subject, :class => 'input input--text'
+ .fieldSet__field
+ = label_tag :message_plain_body, "Body", :class => 'fieldSet__label'
+ .fieldSet__input= text_area_tag "message[plain_body]", @message.plain_body, :class => 'input input--area'
+ .fieldSetSubmit.buttonSet
+ = submit_tag "Send Message", :class => 'button button--positive js-form-submit'
+
diff --git a/app/views/messages/outgoing.html.haml b/app/views/messages/outgoing.html.haml
new file mode 100644
index 0000000..10e98a1
--- /dev/null
+++ b/app/views/messages/outgoing.html.haml
@@ -0,0 +1,16 @@
+- page_title << @server.name
+- page_title << "Messages"
+- page_title << "Outgoing"
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :messages
+= render 'header', :active_nav => :outgoing
+- if @messages[:records].empty? && !@queried
+ .pageContent--compact
+ .noData.noData--deer.noData--clean
+ %h2.noData__title Oh deer! You haven't sent any messages yet.
+ %p.noData__text
+ You haven't sent any messages through this mail server yet. Not to worry though
+ they'll start appearing here as soon as you start sending them.
+- else
+ = render 'index'
+
diff --git a/app/views/messages/plain.html.haml b/app/views/messages/plain.html.haml
new file mode 100644
index 0000000..035724b
--- /dev/null
+++ b/app/views/messages/plain.html.haml
@@ -0,0 +1,17 @@
+- page_title << @server.name
+- page_title << "Messages"
+- page_title << "Message ##{@message.id}"
+- page_title << "Plain Text"
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :messages
+= render 'header', :active_nav => @message.scope.to_sym
+= render 'message_header', :active_nav => :plain
+.pageContent.pageContent--compact
+ - if @message.plain_body.blank?
+ .noData.noData--clean.noData--owl
+ %h2.noData__title There's no plain text body for this message.
+ %p.noData__text
+ This means that we no longer store the raw data for this e-mail
+ or the e-mail didn't include a plain text part.
+ - else
+ %pre.codeBlock= @message.plain_body
diff --git a/app/views/messages/show.html.haml b/app/views/messages/show.html.haml
new file mode 100644
index 0000000..dc5ffd8
--- /dev/null
+++ b/app/views/messages/show.html.haml
@@ -0,0 +1,76 @@
+- page_title << @server.name
+- page_title << "Messages"
+- page_title << "Message ##{@message.id}"
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :messages
+= render 'header', :active_nav => @message.scope.to_sym
+= render 'message_header', :active_nav => :properties
+.pageContent
+ .messagePropertiesPage
+ .messagePropertiesPage__left
+ .messagePropertiesPage__propertyPair
+ %dl.messagePropertiesPage__property
+ %dt Spam Status
+ %dd
+ = link_to spam_checks_organization_server_message_path(organization, @server, @message.id) do
+ %span.label.label--large{:class => "label--spamStatus-#{@message.spam_status.underscore}"}= @message.spam_status.underscore.humanize
+ %dl.messagePropertiesPage__property
+ %dt Tag
+ %dd= @message.tag ? link_to(@message.tag, send("#{@message.scope}_organization_server_messages_path", organization, @server, :query => "tag: #{@message.tag}"), :class => "u-link") : "Not tagged"
+ .messagePropertiesPage__propertyPair
+ %dl.messagePropertiesPage__property
+ %dt Raw Message
+ %dd= @message.raw_message? ? "Available" : "Removed"
+ %dl.messagePropertiesPage__property
+ %dt Message Size
+ %dd= @message.size ? number_to_human_size(@message.size) : "n/a"
+
+ .messagePropertiesPage__propertyPair
+ - if @message.scope == 'incoming'
+ %dl.messagePropertiesPage__property
+ %dt Route
+ %dd
+ - if @message.route
+ = link_to @message.route.name, [:edit, organization, @server, @message.route], :class => "u-link"
+ - else
+ Unknown Route
+ %dl.messagePropertiesPage__property
+ %dt Domain
+ %dd
+ - if @message.domain
+ = link_to @message.domain.name, [organization, @server, :domains], :class => "u-link"
+ - else
+ Unknown Domain
+ - else
+ %dl.messagePropertiesPage__property
+ %dt Credential
+ %dd
+ - if @message.credential
+ = link_to @message.credential.name, [:edit, organization, @server, @message.credential], :class => "u-link"
+ - else
+ Unknown Credential
+ %dl.messagePropertiesPage__property
+ %dt Domain
+ %dd
+ - if @message.domain
+ = link_to @message.domain.name, [organization, @server, :domains], :class => "u-link"
+ - else
+ Unknown Domain
+ - if @message.threat == 1
+ %dl.messagePropertiesPage__property
+ %dt Threat
+ %dd= @message.threat_details
+ %dl.messagePropertiesPage__property
+ %dt Message ID
+ %dd= @message.message_id || "No message ID"
+ - unless @message.received_with_ssl.nil?
+ %dl.messagePropertiesPage__property
+ %dt Transport Security
+ - if @message.received_with_ssl == 1
+ %dd.messagePropertiesPage__property--locked Received over a SSL connection
+ - else
+ %dd Not received with SSL
+
+ .messagePropertiesPage__right
+ = render 'deliveries', :message => @message
+
diff --git a/app/views/messages/spam_checks.html.haml b/app/views/messages/spam_checks.html.haml
new file mode 100644
index 0000000..95483b6
--- /dev/null
+++ b/app/views/messages/spam_checks.html.haml
@@ -0,0 +1,31 @@
+- page_title << @server.name
+- page_title << "Messages"
+- page_title << "Message ##{@message.id}"
+- page_title << "Spam checks"
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :messages
+= render 'header', :active_nav => @message.scope.to_sym
+= render 'message_header', :active_nav => :spam_checks
+.pageContent.pageContent--compact
+
+ - if @spam_checks.empty?
+ .noData.noData--clean
+ %h2.noData__title This message doesn't have any spam checks.
+ %p.noData__text
+ This likely means we haven't scanned this message to determine its likelyhood
+ of being spam. It may take a few seconds to appear after a new message is
+ received.
+
+ - else
+ %ul.spamCheckList
+ %li.spamCheckList__item.spamCheckList__item--total
+ %p.spamCheckList__score{:class => @message.spam_score <= 0 ? (@message.spam_score == 0 ? 'spamCheckList__score--neutral' : 'spamCheckList__score--positive') : 'spamCheckList__score--negative'}= @message.spam_score
+ .spamCheckList__details.spamCheckList__details--total
+ Total spam score for e-mail
+ - for spam_check in @spam_checks
+ %li.spamCheckList__item
+ %p.spamCheckList__score{:class => spam_check['score'] <= 0 ? (spam_check['score'] == 0 ? 'spamCheckList__score--neutral' : 'spamCheckList__score--positive') : 'spamCheckList__score--negative'}= spam_check['score']
+ .spamCheckList__details
+ %p.spamCheckList__code= spam_check['code']
+ %p.spamCheckList__description= spam_check['description']
+
diff --git a/app/views/messages/suppressions.html.haml b/app/views/messages/suppressions.html.haml
new file mode 100644
index 0000000..9bdb465
--- /dev/null
+++ b/app/views/messages/suppressions.html.haml
@@ -0,0 +1,26 @@
+- page_title << @server.name
+- page_title << "Messages"
+- page_title << "Suppression List"
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :messages
+= render 'header', :active_nav => :suppressions
+.pageContent.pageContent--compact
+ - if @suppressions[:records].empty?
+ .noData.noData--clean.noData--owl
+ %h2.noData__title No addresses on the suppression list.
+ %p.noData__text
+ When messages cannot be delivered, addresses are added to the suppression list which stops
+ future messages to the same recipient being sent through.
+ - else
+ %p.pageContent__intro.u-margin
+ When messages cannot be delivered, addresses are added to the suppression list which stops
+ future messages to the same recipient being sent through. Recipients are removed from the list after 30 days.
+ %ul.suppressionList
+ - for suppression in @suppressions[:records]
+ %li.suppressionList__item
+ .suppressionList__left
+ %p.suppressionList__address= link_to suppression['address'], outgoing_organization_server_messages_path(organization, @server, :query => "to: #{suppression['address']}")
+ %p.suppressionList__reason= suppression['reason'].capitalize
+ .suppressionList__right
+ %p.suppressionList__timestamp Added #{Time.at(suppression['timestamp']).to_s(:long)}
+ = render 'shared/message_db_pagination', :data => @suppressions, :name => "suppression"
diff --git a/app/views/organizations/_nav.html.haml b/app/views/organizations/_nav.html.haml
new file mode 100644
index 0000000..623e1e2
--- /dev/null
+++ b/app/views/organizations/_nav.html.haml
@@ -0,0 +1,10 @@
+- if organization.admin?(current_user)
+ .navBar
+ %ul
+ %li.navBar__item= link_to "Mail Servers", organization_root_path(organization), :class => ['navBar__link', active_nav == :servers ? 'is-active' : '']
+ %li.navBar__item= link_to "Domains", organization_domains_path(organization), :class => ['navBar__link', active_nav == :domains ? 'is-active' : '']
+ %li.navBar__item= link_to "Settings", organization_settings_path(organization), :class => ['navBar__link', active_nav == :settings ? 'is-active' : '']
+ %li.navBar__item= link_to "IPs", organization_ip_pools_path(organization), :class => ['navBar__link', active_nav == :ips ? 'is-active' : '']
+ %li.navBar__item= link_to "Users", organization_users_path(organization), :class => ['navBar__link', active_nav == :users ? 'is-active' : '']
+ - if organization.owner?(current_user)
+ %li.navBar__item= link_to "Delete Organization", organization_delete_path(organization), :class => ['navBar__link', active_nav == :delete ? 'is-active' : '']
diff --git a/app/views/organizations/delete.html.haml b/app/views/organizations/delete.html.haml
new file mode 100644
index 0000000..8a53641
--- /dev/null
+++ b/app/views/organizations/delete.html.haml
@@ -0,0 +1,24 @@
+- page_title << organization.name
+- page_title << "Delete"
+.pageHeader
+ %h1.pageHeader__title
+ %span.pageHeader__titlePrevious
+ = organization.name
+ →
+ Delete organization
+= render 'nav', :active_nav => :delete
+.pageContent.pageContent--compact
+ %h2.pageContent__intro.u-margin
+ If you no longer need this organization you can delete it. When you delete an organization
+ all its mail servers & data will be deleted from our systems.
+ .dangerZone
+ %p.pageContent__text.u-margin
+ To continue to delete this organization, please enter your password in the field below and press
+ continue. There will be no other confirmations.
+ = form_tag [organization, :delete], :method => :delete, :remote => true do
+ = hidden_field_tag 'return_to', params[:return_to]
+ %p.u-margin
+ = password_field_tag "password", '', :class => 'input input--text input--danger'
+ .buttonSet.u-center
+ = submit_tag "Delete this organization, mail servers and all messages", :class => 'button button--danger js-form-submit'
+
diff --git a/app/views/organizations/edit.html.haml b/app/views/organizations/edit.html.haml
new file mode 100644
index 0000000..f62a169
--- /dev/null
+++ b/app/views/organizations/edit.html.haml
@@ -0,0 +1,28 @@
+- page_title << @organization.name
+- page_title << "Organization Settings"
+.pageHeader
+ %h1.pageHeader__title
+ %span.pageHeader__titlePrevious
+ = @organization.name
+ →
+ Settings
+
+= render 'nav', :active_nav => :settings
+
+.pageContent.pageContent--compact
+ = form_for @organization_obj, :url => organization_settings_path(@organization_obj), :remote => true do |f|
+ = f.error_messages
+ %fieldset.fieldSet
+ .fieldSet__field
+ = f.label :name, :class => 'fieldSet__label'
+ .fieldSet__input= f.text_field :name, :autofocus => true, :class => 'input input--text'
+ .fieldSet__field
+ = f.label :time_zone, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.time_zone_select :time_zone, [], {}, :class => 'input input--select'
+ %p.fieldSet__text
+ Choose the time zone that your organization exists within. This is used when displaying times in places
+ where there isn't a logged in user to provide their own time zone.
+
+ %p.fieldSetSubmit.buttonSet
+ = f.submit "Save Settings", :class => 'button button--positive js-form-submit'
diff --git a/app/views/organizations/index.html.haml b/app/views/organizations/index.html.haml
new file mode 100644
index 0000000..b3a6adc
--- /dev/null
+++ b/app/views/organizations/index.html.haml
@@ -0,0 +1,28 @@
+- page_title << "Welcome"
+
+.pageHeader
+ %h1.pageHeader__title Welcome to Postal, #{current_user.first_name}
+
+.pageContent.pageContent--compact
+
+ - if @organizations.empty?
+ .noData.noData--panda.noData--clean
+ %p.noData__title This is unbearable! You don't have any organizations.
+ %p.noData__text
+ That's not on. You need an organization otherwise you can't do much here. Hit
+ the button below and you'll be up and running with 10,000 free e-mails before
+ you know it.
+ %p.noData__button= link_to "Create your first organization", :new_organization, :class => 'button button--positive'
+ - else
+ %p.pageContent__intro.u-margin
+ Organizations are entities which are able to deploy mail servers.
+ Choose an existing organization from the list opposite or use the button below
+ to create a new one.
+
+ %ul.largeList.u-margin
+ - for organization in @organizations
+ %li.largeList__item
+ = link_to organization_root_path(organization), :class => 'largeList__link' do
+ = organization.name
+
+ %p.u-center= link_to "Start another organization", :new_organization, :class => 'button button--positive'
diff --git a/app/views/organizations/new.html.haml b/app/views/organizations/new.html.haml
new file mode 100644
index 0000000..e478a8c
--- /dev/null
+++ b/app/views/organizations/new.html.haml
@@ -0,0 +1,25 @@
+- page_title << "Create a new organization"
+.pageHeader
+ %h1.pageHeader__title Create a new organization
+.pageContent.pageContent--compact
+ %p.pageContent__intro.u-margin
+ If you're starting a new organization you can do so by completing this form. You'll be able
+ to invite new users & create mail servers as soon as it has been created.
+ = form_for @organization, :remote => true do |f|
+ = f.error_messages
+ %fieldset.fieldSet
+ .fieldSet__field
+ = f.label :name, :class => 'fieldSet__label'
+ .fieldSet__input= f.text_field :name, :autofocus => true, :class => 'input input--text'
+ .fieldSet__field
+ = f.label :permalink, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.text_field :permalink, :class => 'input input--text', :placeholder => "Automatically generated"
+ %p.fieldSet__text
+ This is a short name which is used in usernames and the API to identify your organization.
+ It should only contain letters, numbers & hyphens.
+
+ .fieldSetSubmit.buttonSet
+ = f.submit "Create organization", :class => 'button button--positive js-form-submit'
+ .fieldSetSubmit__delete
+ = link_to "Back to homepage", root_path, :class => 'button button--neutral'
diff --git a/app/views/routes/_form.html.haml b/app/views/routes/_form.html.haml
new file mode 100644
index 0000000..d2992c7
--- /dev/null
+++ b/app/views/routes/_form.html.haml
@@ -0,0 +1,56 @@
+= form_for [organization, @server, @route], :remote => true do |f|
+ = f.error_messages
+ %fieldset.fieldSet
+ .fieldSet__field
+ = f.label :name, :class => 'fieldSet__label'
+ .fieldSet__input
+ .routeNameInput
+ = f.text_field :name, :autofocus => true, :class => 'input input--text routeNameInput__name'
+ %span.routeNameInput__at @
+ = f.select :domain_id, domain_options_for_select(@server, @route.domain), {}, :class => 'input input--select routeNameInput__domain'
+ %p.fieldSet__text
+ Enter the address you wish to route. In addition to the name you enter, you'll also received "tagged" mail for this
+ address. See our documentation for details about tagged mail.
+ .fieldSet__field
+ = f.label :_endpoint, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.select :_endpoint, endpoint_options_for_select(@server, @route._endpoint), {}, :class => 'input input--select'
+ %p.fieldSet__text
+ This is the endpoint where mail to this address will be delivered to. If you need to add different endpoints,
+ you can do this using the links above this form.
+
+ .fieldSet__field
+ = f.label :_endpoint, "Additional Endpoints", :class => 'fieldSet__label'
+ .fieldSet__input
+ .fieldSet__selectList
+ - for endpoint in @route.additional_route_endpoints_array
+ = select_tag "route[additional_route_endpoints_array][]", endpoint_options_for_select(@server, endpoint, :other => false), :class => 'input input--select'
+ = select_tag "route[additional_route_endpoints_array][]", endpoint_options_for_select(@server, nil, :other => false), :class => 'input input--select'
+
+ %p.fieldSet__text
+ If you wish to deliver a message to multiple endpoints, you can do so by choosing them from the list above.
+
+ .fieldSet__field
+ = f.label :spam_mode, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.select :spam_mode, Route::SPAM_MODES, {}, :class => 'input input--select'
+ %p.fieldSet__text
+ You can choose what should happen to mail which we identify as spam. If you choose Mark we'll tell you
+ we think its spam when we deliver it to your endpoint. If you choose Quarantine, we won't send the message
+ to you at all and you'll have manually accept it through our web interface or the API if you want it delivered.
+ If you choose Fail, the message will simply be failed without any attempt to deliver your message.
+ - if @route.persisted?
+ .fieldSet__field
+ = f.label :forward_address, "Address", :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.text_field :forward_address, :class => 'input input--text', :readonly => true
+ %p.fieldSet__text
+ If you don't wish to point your MX records to our server, you can redirect your mail to this address and
+ will be routed to your endpoint as if it was sent to the address you entered above.
+
+ .fieldSetSubmit.buttonSet
+ = f.submit @route.new_record? ? "Create route" : "Save route", :class => 'button button--positive js-form-submit'
+ .fieldSetSubmit__delete
+ - if f.object.persisted?
+ = link_to "Delete route", [organization, @server, @route], :remote => true, :class => 'button button--danger', :method => :delete, :data => {:confirm => "Are you sure you wish to delete this route?"}
+
diff --git a/app/views/routes/_header.html.haml b/app/views/routes/_header.html.haml
new file mode 100644
index 0000000..4283bf5
--- /dev/null
+++ b/app/views/routes/_header.html.haml
@@ -0,0 +1,6 @@
+.navBar.navBar--secondary
+ %ul
+ %li.navBar__item= link_to "Routes", [organization, @server, :routes], :class => ['navBar__link', active_nav == :routes ? 'is-active' : '']
+ %li.navBar__item= link_to "HTTP Endpoints", [organization, @server, :http_endpoints], :class => ['navBar__link', active_nav == :http_endpoints ? 'is-active' : '']
+ %li.navBar__item= link_to "SMTP Endpoints", [organization, @server, :smtp_endpoints], :class => ['navBar__link', active_nav == :smtp_endpoints ? 'is-active' : '']
+ %li.navBar__item= link_to "Address Endpoints", [organization, @server, :address_endpoints], :class => ['navBar__link', active_nav == :address_endpoints ? 'is-active' : '']
diff --git a/app/views/routes/edit.html.haml b/app/views/routes/edit.html.haml
new file mode 100644
index 0000000..3c4e687
--- /dev/null
+++ b/app/views/routes/edit.html.haml
@@ -0,0 +1,10 @@
+- page_title << @server.name
+- page_title << "Routes"
+- page_title << "Edit Route"
+
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :routing
+= render 'header', :active_nav => :routes
+
+.pageContent.pageContent--compact
+ = render 'form'
diff --git a/app/views/routes/index.html.haml b/app/views/routes/index.html.haml
new file mode 100644
index 0000000..7beaea6
--- /dev/null
+++ b/app/views/routes/index.html.haml
@@ -0,0 +1,47 @@
+- page_title << @server.name
+- page_title << "Routes"
+
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :routing
+= render 'header', :active_nav => :routes
+
+.pageContent.pageContent--compact
+ - if @routes.empty?
+ .noData.noData--monkey.noData--clean
+ %h2.noData__title We heard on the ape vine, you've got no routes yet.
+ %p.noData__text
+ To receive incoming mail, you need to add routes so where we should send
+ messages we receive for your domain. You can send incoming e-mail to
+ HTTP endpoints or other SMTP servers.
+
+ - if @server.smtp_endpoints.empty? && @server.http_endpoints.empty?
+ %p.noData__button.buttonSet.buttonSet--center
+ = link_to "Add a HTTP endpoint", new_organization_server_http_endpoint_path(organization, @server, :return_to => new_organization_server_route_path(organization, @server), :return_notice => "You can now go ahead and add your first route for this HTTP endpoint"), :class => 'button button--positive'
+ = link_to "Add a SMTP endpoint", new_organization_server_smtp_endpoint_path(organization, @server, :return_to => new_organization_server_route_path(organization, @server), :return_notice => "You can now go ahead and add your first route for this SMTP endpoint"), :class => 'button button--positive'
+ %p.noData__postButtonText
+ Once you've added these, you'll be able to come back here to route a
+ specific e-mail address to your newly created endpoint. You can
+ #{link_to "add a route without an endpoint", new_organization_server_route_path(organization, @server), :class => "u-link"} if you really want.
+ - else
+ %p.noData__button
+ = link_to "Add your first route", [:new, organization, @server, :route], :class => 'button button--positive'
+ - else
+ %p.pageContent__intro.u-margin
+ Routes control where incoming mail for your domain is sent. Messages can be sent to
+ HTTP endpoints or other SMTP servers.
+ %p.u-margin.pageContent__helpLink= link_to "Read more about receiving e-mails", [organization, @server, :help_incoming]
+
+ %ul.routeList.u-margin
+ - for route in @routes
+ %li.routeList__item
+ = link_to [:edit, organization, @server, route], :class => 'routeList__link' do
+ %p.routeList__name= route.description
+ .routeList__details
+ %p.routeList__endpoint{:class => "routeList__endpoint--#{route.endpoint_type&.underscore || 'none'}"}
+ - if route.mode == 'Endpoint'
+ = route.endpoint.description
+ - else
+ = t("route_modes.#{route.mode.underscore}")
+ %p.routeList__spamMode= t("route_spam_modes.#{route.spam_mode.underscore}")
+
+ %p.u-center= link_to "Add another route", [:new, organization, @server, :route], :class => 'button button--positive'
diff --git a/app/views/routes/new.html.haml b/app/views/routes/new.html.haml
new file mode 100644
index 0000000..dbd0a15
--- /dev/null
+++ b/app/views/routes/new.html.haml
@@ -0,0 +1,10 @@
+- page_title << @server.name
+- page_title << "Routes"
+- page_title << "Add Route"
+
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :routing
+= render 'header', :active_nav => :routes
+
+.pageContent.pageContent--compact
+ = render 'form'
diff --git a/app/views/servers/_form.html.haml b/app/views/servers/_form.html.haml
new file mode 100644
index 0000000..faa0834
--- /dev/null
+++ b/app/views/servers/_form.html.haml
@@ -0,0 +1,52 @@
+= form_for [organization, @server], :remote => true do |f|
+ = f.error_messages
+ %fieldset.fieldSet
+ .fieldSet__field
+ = f.label :name, :class => 'fieldSet__label'
+ .fieldSet__input= f.text_field :name, :autofocus => true, :class => 'input input--text'
+ .fieldSet__field
+ = f.label :permalink, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.text_field :permalink, :class => 'input input--text', :placeholder => "Automatically generated", :disabled => @server.persisted?
+ %p.fieldSet__text
+ This is a short name which is used in usernames and the API to identify your organization.
+ It should only contain letters, numbers & hyphens.
+ .fieldSet__field
+ = f.label :mode, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.select :mode, Server::MODES, {}, :autofocus => true, :class => 'input input--select'
+ %p.fieldSet__text
+ The mode you choose will determine how messages are handled. When in Live mode, all
+ e-mail will be routed normally to the intended recipients. When in Development mode,
+ outgoing & incoming mail will be held and only visible in the web interface and will not be
+ sent to any recipients or HTTP endpoints.
+ .fieldSet__field
+ = f.label :ip_pool_id, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.collection_select :ip_pool_id, organization.ip_pools.includes(:ip_addresses).order("`default` desc, name asc"), :id, :description, {}, :class => 'input input--select'
+ %p.fieldSet__text
+ This is the set of IP addresses which outbound e-mails will be delivered from.
+
+ - if @server.persisted?
+ .fieldSet__field
+ = f.label :allow_sender, "Send as any", :class => 'fieldSet__label'
+ .fieldSet__input
+ .input.is-disabled= @server.allow_sender? ? "Enabled" : "Disabled"
+ %p.fieldSet__text
+ When enabled, you will be able to use any e-mail address in the From header on outgoing e-mails.
+ You will need to add a Sender header which must be an address at one of your verified domains.
+
+ .fieldSet__field
+ = f.label :postmaster_address, "Postmaster", :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.text_field :postmaster_address, :class => 'input input--text', :placeholder => "Set based on the domain"
+ %p.fieldSet__text
+ This is the e-mail address that is included in any bounce messages that are sent when incoming
+ messages cannot be delivered. By default, the address is postmaster@[yourdomain.com].
+
+ .fieldSetSubmit.buttonSet
+ = f.submit f.object.new_record? ? "Build server" : "Save server", :class => 'button button--positive js-form-submit'
+ .fieldSetSubmit__delete
+ - unless f.object.persisted?
+ = link_to "Back to server list", organization_root_path(organization), :class => 'button button--neutral'
+
diff --git a/app/views/servers/_header.html.haml b/app/views/servers/_header.html.haml
new file mode 100644
index 0000000..0a2eff0
--- /dev/null
+++ b/app/views/servers/_header.html.haml
@@ -0,0 +1,60 @@
+.serverHeader
+ .serverHeader__stripe{:class => "serverHeader__stripe--#{@server.status.underscore}"}= @server.status
+
+ .serverHeader__info
+ %p.serverHeader__title= @server.name
+
+ %ul.serverHeader__list
+ - total, unverified, bad_dns = @server.domain_stats
+ - if total == 0
+ %li No domains have been added for this server
+ - elsif bad_dns == 0
+ %li.serverHeader__list--ok DKIM & SPF configured correctly on #{pluralize total - unverified, 'domain'}
+ - else
+ %li.serverHeader__list--warning= link_to "#{pluralize bad_dns, 'domain'} has misconfigured DNS records", [organization, @server, :domains]
+ - if unverified > 0
+ %li= link_to "#{pluralize unverified, 'domain'} is awaiting verification", [organization, @server, :domains]
+ %li Sending via #{@server.ip_pool.name}
+
+ .serverHeader__stats{"data-turbolinks-permanent" => true, :id => "serverStats-#{@server.uuid}"}
+ %ul.serverHeader__statsList
+ %li.serverHeader__stat-held
+ = link_to "#{pluralize @server.held_messages, 'message'} held", held_organization_server_messages_path(organization, @server), :class => 'js-held-count'
+ %li.serverHeader__stat-queue
+ = link_to pluralize(@server.queue_size, 'queued message'), queue_organization_server_path(organization, @server), :class => "js-queue-size"
+ %li.serverHeader__stat-bounces
+ = link_to "#{number_to_percentage @server.bounce_rate, :precision => 1} bounce rate", outgoing_organization_server_messages_path(organization, @server, :query => "status: hardfail status:bounced"), :class => 'js-bounce-rate'
+ %li.serverHeader__stat-size
+ = link_to "#{number_to_human_size @server.message_db.total_size} used", [:retention, organization, @server], :class => 'js-disk-size'
+
+ .serverHeader__usage{"data-turbolinks-permanent" => true, :id => "serverUsage-#{@server.uuid}"}
+ %p.serverHeader__usageTitle Message throughput — last 60 minutes
+
+ .serverHeader__usageLine
+ .serverHeader__usageLineLabel Outgoing messages
+ .serverHeader__usageLineBar
+ .bar
+ .bar__inner.js-outgoing-bar{:style => style_width(@server.throughput_stats[:outgoing_usage], :color => true)}
+ .serverHeader__usageLineValue.js-outgoing-count{:title => "Limit: #{@server.send_limit || '∞'} every 60 minutes"}
+ = number_with_delimiter @server.throughput_stats[:outgoing]
+ .serverHeader__usageLine
+ .serverHeader__usageLineLabel Incoming messages
+ .serverHeader__usageLineValue.js-incoming-count
+ = number_with_delimiter @server.throughput_stats[:incoming]
+
+ .serverHeader__usageLine
+ .serverHeader__usageLineLabel Message Rate
+ .serverHeader__usageLineValueLarge
+ %b.js-message-rate= number_with_precision @server.message_rate, :precision => 2
+ messages/minute
+
+.navBar
+ %ul
+ %li.navBar__item= link_to "Overview", [organization, @server], :class => ['navBar__link', active_nav == :overview ? 'is-active' : '']
+ %li.navBar__item= link_to "Messages", [:outgoing, organization, @server, :messages], :class => ['navBar__link', active_nav == :messages ? 'is-active' : '']
+ %li.navBar__item= link_to "Domains", [organization, @server, :domains], :class => ['navBar__link', active_nav == :domains ? 'is-active' : '']
+ %li.navBar__item= link_to "Routing", [organization, @server, :routes], :class => ['navBar__link', active_nav == :routing ? 'is-active' : '']
+ %li.navBar__item= link_to "Credentials", [organization, @server, :credentials], :class => ['navBar__link', active_nav == :credentials ? 'is-active' : '']
+ %li.navBar__item= link_to "Webhooks", [organization, @server, :webhooks], :class => ['navBar__link', active_nav == :webhooks ? 'is-active' : '']
+ %li.navBar__item= link_to "Settings", [:edit, organization, @server], :class => ['navBar__link', active_nav == :settings ? 'is-active' : '']
+ %li.navBar__item.navBar__item--end= link_to "Help", [organization, @server, :help_outgoing], :class => ['navBar__link', active_nav == :help ? 'is-active' : '']
diff --git a/app/views/servers/_settings_header.html.haml b/app/views/servers/_settings_header.html.haml
new file mode 100644
index 0000000..0de077d
--- /dev/null
+++ b/app/views/servers/_settings_header.html.haml
@@ -0,0 +1,11 @@
+.navBar.navBar--secondary
+ %ul
+ %li.navBar__item= link_to "Server Settings", [:edit, organization, @server], :class => ['navBar__link', active_nav == :settings ? 'is-active' : '']
+ %li.navBar__item= link_to "Spam", [:spam, organization, @server], :class => ['navBar__link', active_nav == :spam ? 'is-active' : '']
+ %li.navBar__item= link_to "Retention", [:retention, organization, @server], :class => ['navBar__link', active_nav == :retention ? 'is-active' : '']
+ %li.navBar__item= link_to "Send Limit", [:limits, organization, @server], :class => ['navBar__link', active_nav == :limits ? 'is-active' : '']
+ %li.navBar__item= link_to "IP Rules", [organization, @server, :ip_pool_rules], :class => ['navBar__link', active_nav == :ip_pool_rules ? 'is-active' : '']
+ - if current_user.admin?
+ %li.navBar__item= link_to "Admin", [:admin, organization, @server], :class => ['navBar__link', active_nav == :admin ? 'is-active' : '']
+ - if organization.admin?(current_user)
+ %li.navBar__item= link_to "Delete", [:delete, organization, @server], :class => ['navBar__link', active_nav == :delete ? 'is-active' : '']
diff --git a/app/views/servers/_sidebar.html.haml b/app/views/servers/_sidebar.html.haml
new file mode 100644
index 0000000..3ba2ed4
--- /dev/null
+++ b/app/views/servers/_sidebar.html.haml
@@ -0,0 +1,17 @@
+- servers = organization.servers.present.order(:name).to_a
+
+= content_for :sidebar do
+ .js-searchable
+ = form_tag '', :class => 'sidebar__search js-searchable__input' do
+ = text_field_tag 'query', '', :class => 'sidebar__searchInput js-focus-on-s', :placeholder => "Filter servers..."
+ %p.sidebar__placeholder.js-searchable__empty{:class => ("is-hidden" if servers.any?)}
+ No servers found.
+ %ul.sidebarServerList.js-searchable__list{:class => ("is-hidden" if servers.empty?)}
+ - for server in servers
+ %li.sidebarServerList__item.js-searchable__item{:data => {:url => organization_server_path(organization, server), :value => server.name.downcase.gsub(/\W/, '')}}
+ = link_to [organization, server], :class => ['sidebarServerList__link', (active_server == server ? 'is-active' : '')] do
+ %p.sidebarServerList__mode.label{:class => "label--serverStatus-#{server.status.underscore}"}= t("server_statuses.#{server.status.underscore}")
+ %p.sidebarServerList__title= server.name
+ %p.sidebarServerList__quantity #{number_with_precision server.message_rate, :precision => 2} messages/minute
+ - if organization.admin?(current_user)
+ %p.sidebar__new= link_to "Build a new mail server", [:new, organization, :server]
diff --git a/app/views/servers/admin.html.haml b/app/views/servers/admin.html.haml
new file mode 100644
index 0000000..466f328
--- /dev/null
+++ b/app/views/servers/admin.html.haml
@@ -0,0 +1,53 @@
+- page_title << @server.name
+- page_title << "Settings"
+- page_title << "Admin"
+= render 'sidebar', :active_server => @server
+= render 'header', :active_nav => :settings
+= render 'settings_header', :active_nav => :admin
+.pageContent.pageContent--compact
+ .u-margin
+ = form_for [organization, @server], :remote => true do |f|
+ = f.error_messages
+ %fieldset.fieldSet.fieldSet--wide
+ .fieldSet__field
+ = f.label :send_limit, :class => 'fieldSet__label'
+ .fieldSet__input= f.text_field :send_limit, :autofocus => true, :class => 'input input--text'
+ .fieldSet__field
+ = f.label :allow_sender, "Allow sender header", :class => 'fieldSet__label'
+ .fieldSet__input= f.select :allow_sender, [["No", false], ["Yes - can use Sender header", true]], {}, :class => 'input input--select'
+ .fieldSet__field
+ = f.label :log_smtp_data, "Log SMTP data?", :class => 'fieldSet__label'
+ .fieldSet__input= f.select :log_smtp_data, [["No", false], ["Yes - log all SMTP DATA (debug only)", true]], {}, :class => 'input input--select'
+ .fieldSet__field
+ = f.label :outbound_spam_threshold, :class => 'fieldSet__label'
+ .fieldSet__input= f.text_field :outbound_spam_threshold, :class => 'input input--text', :placeholder => "No outbound spam checking"
+ .fieldSet__field
+ = f.label :message_retention_days, :class => 'fieldSet__label'
+ .fieldSet__input= f.text_field :message_retention_days, :class => 'input input--text'
+ .fieldSet__field
+ = f.label :raw_message_retention_days, :class => 'fieldSet__label'
+ .fieldSet__input= f.text_field :raw_message_retention_days, :class => 'input input--text'
+ .fieldSet__field
+ = f.label :raw_message_retention_size, :class => 'fieldSet__label'
+ .fieldSet__input= f.text_field :raw_message_retention_size, :class => 'input input--text'
+ .fieldSet__field
+ = f.label :ip_pool_id, :class => 'fieldSet__label'
+ .fieldSet__input= f.collection_select :ip_pool_id, IPPool.order(:name), :id, :name, {}, :class => 'input input--select'
+
+ .fieldSetSubmit.fieldSetSubmit--wide.buttonSet
+ = f.submit "Save server", :class => 'button button--positive js-form-submit'
+
+ - if @server.suspended_at
+ = form_tag [:unsuspend, organization, @server], :remote => true do
+ .fieldSetSubmit.fieldSetSubmit--wide.buttonSet
+ = submit_tag "Unsuspend server", :class => 'button button--danger js-form-submit'
+
+ - else
+ = form_tag [:suspend, organization, @server], :remote => true do
+ %fieldset.fieldSet.fieldSet--wide
+ .fieldSet__field
+ = label_tag :reason, 'Suspension Reason', :class => 'fieldSet__label'
+ .fieldSet__input= text_field_tag :reason, '', :class => 'input input--text', :required => true
+
+ .fieldSetSubmit.fieldSetSubmit--wide.buttonSet
+ = submit_tag "Suspend server", :class => 'button button--positive js-form-submit'
diff --git a/app/views/servers/delete.html.haml b/app/views/servers/delete.html.haml
new file mode 100644
index 0000000..604cf06
--- /dev/null
+++ b/app/views/servers/delete.html.haml
@@ -0,0 +1,21 @@
+- page_title << @server.name
+- page_title << "Delete Server"
+= render 'sidebar', :active_server => @server
+= render 'header', :active_nav => :settings
+= render 'settings_header', :active_nav => :delete
+.pageContent.pageContent--compact
+ %h2.pageContent__intro.u-margin
+ If you no longer need this server you can remove it. When you remove a server all
+ retained messages will be deleted and all mail which is received will be rejected
+ immediately.
+ .dangerZone
+ %p.pageContent__text.u-margin
+ To continue to remove this server, please enter your password in the field below and press
+ continue. There will be no other confirmations.
+ = form_tag [organization, @server], :remote => true, :method => :delete do
+ = hidden_field_tag 'return_to', params[:return_to]
+ %p.u-margin
+ = password_field_tag "password", '', :class => 'input input--text input--danger'
+ .buttonSet.u-center
+ = submit_tag "Delete this mail server and all messages", :class => 'button button--danger'
+
diff --git a/app/views/servers/edit.html.haml b/app/views/servers/edit.html.haml
new file mode 100644
index 0000000..6ffb27c
--- /dev/null
+++ b/app/views/servers/edit.html.haml
@@ -0,0 +1,8 @@
+- page_title << @server.name
+- page_title << "Settings"
+= render 'sidebar', :active_server => @server
+= render 'header', :active_nav => :settings
+= render 'settings_header', :active_nav => :settings
+.pageContent.pageContent--compact
+ = render 'form'
+
diff --git a/app/views/servers/index.html.haml b/app/views/servers/index.html.haml
new file mode 100644
index 0000000..df4ca6a
--- /dev/null
+++ b/app/views/servers/index.html.haml
@@ -0,0 +1,35 @@
+- page_title << "Choose mail server"
+.pageHeader
+ %h1.pageHeader__title
+ %span.pageHeader__titlePrevious
+ = @organization.name
+ →
+ Mail Servers
+= render 'organizations/nav', :active_nav => :servers
+.pageContent.pageContent--compact
+
+ - if @servers.empty?
+ .noData.noData--owl.noData--clean
+ %p.noData__title Well owls about that, you've no mail servers yet.
+ %p.noData__text
+ Great - you've got an organization, now you need to provision a mail server.
+ Once you've got a mail server, you can start sending & receiving messages.
+ %p.noData__button.buttonSet.buttonSet--center
+ = link_to "Build your first mail server", [:new, organization, :server], :class => 'button button--positive'
+ - else
+ .js-searchable
+ %p.messageSearch= text_field_tag 'query', params[:query], :class => 'messageSearch__input js-searchable__input js-focus-on-s', :placeholder => "Find a server..."
+
+ %ul.largeList.u-margin.js-searchable__list
+ - for server in @servers
+ %li.largeList__item.js-searchable__item{:data => {:value => server.name.downcase.gsub(/\W/, ''), :url => url_for([organization, server])}}
+ = link_to [organization, server], :class => 'largeList__link' do
+ %span.largeList__rightLabel.label{:class => "label--serverStatus-#{server.status.underscore}"}= t("server_statuses.#{server.status.underscore}")
+ %p= server.name
+ %p.largeList__subText #{number_with_precision server.message_rate, :precision => 2} messages/minute
+ .js-searchable__empty.is-hidden
+ .noData.noData--owl.noData--clean
+ %p.noData__title Well owls about that, no servers found.
+ %p.noData__text
+ There were no servers found matching what you've typed it.
+ %p.u-center= link_to "Build a new mail server", [:new, organization, :server], :class => 'button button--positive'
diff --git a/app/views/servers/limits.html.haml b/app/views/servers/limits.html.haml
new file mode 100644
index 0000000..287098d
--- /dev/null
+++ b/app/views/servers/limits.html.haml
@@ -0,0 +1,45 @@
+- page_title << @server.name
+- page_title << "Limits"
+= render 'sidebar', :active_server => @server
+= render 'header', :active_nav => :settings
+= render 'settings_header', :active_nav => :limits
+.pageContent.pageContent--compact
+
+ %p.pageContent__intro.u-margin
+ In order to protect our reputation and ensure the resiliency of our service,
+ we implement limits on the amount of e-mail that can pass through your mail
+ server.
+ %p.pageContent__text.u-margin
+ The main limit to be aware of is the amount of e-mail that you can send
+ from your mail server to external recipients in a rolling 60 minute window.
+ New mail servers start with a low limit which is increased automatically as it gets older.
+ Your current limit is shown below.
+
+ %ul.limits.u-margin
+ %li.limits__limit
+ %p.limits__value
+ - if @server.send_limit
+ = number_with_delimiter @server.send_limit
+ - else
+ unlimited
+ %p.limits__frequency e-mails every 60 minutes*
+
+
+ %p.pageContent__text
+ You can view your current usage & limit on the top of right of every mail server
+ page in the web interface. The bars will show you how close you are to reaching the
+ limits. Although we show your incoming mail throughput, it is not limited at present.
+
+ %p.pageContent__subTitle What happens if I reach my limit?
+ %p.pageContent__text
+ If you reach your outgoing limit, any new e-mails that you try to send will be held and
+ will need to be released manually when your usage has dropped.
+
+ %p.pageContent__text
+ You will be notified by e-mail (and with a webhook if enabled) when you are approaching
+ and/or exceeding your limits.
+
+ %p.pageContent__subTitle Can I have a higher limit?
+ %p.pageContent__text
+ If you need higher limits, that's usually not a problem. Just drop us an email with
+ what sort of e-mail you're sending and we'll get your limits increased.
diff --git a/app/views/servers/new.html.haml b/app/views/servers/new.html.haml
new file mode 100644
index 0000000..fc33edb
--- /dev/null
+++ b/app/views/servers/new.html.haml
@@ -0,0 +1,11 @@
+- page_title << "Build new mail server"
+.pageHeader
+ %h1.pageHeader__title
+ %span.pageHeader__titlePrevious
+ = @organization.name
+ →
+ Build a new mail server
+= render 'organizations/nav', :active_nav => :servers
+.pageContent.pageContent--compact
+ = render 'form'
+
diff --git a/app/views/servers/queue.html.haml b/app/views/servers/queue.html.haml
new file mode 100644
index 0000000..50cb375
--- /dev/null
+++ b/app/views/servers/queue.html.haml
@@ -0,0 +1,23 @@
+- page_title << @server.name
+- page_title << "Messages"
+- page_title << "Queue"
+= render 'sidebar', :active_server => @server
+= render 'header', :active_nav => :messages
+= render 'messages/header', :active_nav => :queue
+
+- if @messages.empty?
+ .pageContent--compact
+ .noData.noData--cat2.noData--clean
+ %h2.noData__title Your queue is empty. It won't stay like that fur-ever.
+ %p.noData__text
+ Messages which haven't yet been delivered successfully will appear in your queue until
+ we've delivered them or we've given up trying.
+- else
+ .pageContent
+ %p.pageContent__intro.u-margin
+ All messages that pass through your mail server first enter this queue. Any messages
+ that cannot be delivered immediately remain in the queue until they can be successfully
+ delivered or we give up on them.
+ = render 'messages/list', :messages => @messages_with_message
+
+ = paginate @messages
diff --git a/app/views/servers/retention.html.haml b/app/views/servers/retention.html.haml
new file mode 100644
index 0000000..f98a81d
--- /dev/null
+++ b/app/views/servers/retention.html.haml
@@ -0,0 +1,48 @@
+- page_title << @server.name
+- page_title << "Message Rentention"
+= render 'sidebar', :active_server => @server
+= render 'header', :active_nav => :settings
+= render 'settings_header', :active_nav => :retention
+.pageContent.pageContent--compact
+
+ %p.pageContent__intro.u-margin
+ The length of time that messages are stored by us are shown below. If you need
+ to store messages for longer, please contact us and we can work out a custom
+ plan.
+
+ .retentionLimits
+ %dl.retentionLimits__limit
+ .retentionLimits__label Number of days that raw message data will be stored
+ .retentionLimits__info
+ .retentionLimits__value
+ - if @server.raw_message_retention_days
+ = pluralize @server.raw_message_retention_days, 'day'
+ - else
+ Indefinitely
+ .retentionLimits__text
+ This is the number of whole days that raw message content will be stored by us.
+ Raw message is the actual content of the message including headers & attachments.
+ %dl.retentionLimits__limit
+ .retentionLimits__label Volume of raw message data that will be stored
+ .retentionLimits__info
+ .retentionLimits__value
+ - if @server.raw_message_retention_size
+ = number_to_human_size @server.raw_message_retention_size * 1024 * 1024
+ - else
+ No limit
+ .retentionLimits__text
+ This is the amount of e-mail that can be stored. When you exceed this amount, messages will be removed in
+ whole day increments starting with the oldest stored day.
+
+ %dl.retentionLimits__limit
+ .retentionLimits__label Number of days of message meta data will be available
+ .retentionLimits__info
+ .retentionLimits__value
+ - if @server.message_retention_days
+ = pluralize @server.message_retention_days, 'day'
+ - else
+ Indefinitely
+ .retentionLimits__text
+ This is the number of days of messages that will be available through the web interface.
+ You will be able to view basic meta information & delivery details but raw data might not
+ be available unless it is within the retention periods above.
diff --git a/app/views/servers/show.html.haml b/app/views/servers/show.html.haml
new file mode 100644
index 0000000..711eae1
--- /dev/null
+++ b/app/views/servers/show.html.haml
@@ -0,0 +1,49 @@
+- page_title << @server.name
+= render 'sidebar', :active_server => @server
+= render 'header', :active_nav => :overview
+
+- if @messages.empty?
+ .pageContent--compact
+ .noData.noData--cat.noData--clean
+ %h2.noData__title Stop pro-cat-inating! Your new mail server is ready.
+ %p.noData__text
+ We can't wait to help you get up and running. The first thing you need
+ to do is add & verify your domain so you can send & receive e-mail to/from it.
+ It'll only take a few seconds.
+ %p.noData__button.buttonSet.buttonSet--center
+ = link_to "Read about sending e-mail", [organization, @server, :help_outgoing], :class => "button"
+ = link_to "Read about receiving e-mail", [organization, @server, :help_incoming], :class => "button"
+- else
+ .pageContent
+ - if @server.suspended?
+ .suspensionBox.u-margin
+ %p
+ This server has been suspended and is not permitted to send or receive e-mail.
+ If you have any questions about this please contact our support team for assistance.
+ Please be aware that suspended servers will be fully deleted from our system 30 days after
+ suspension.
+ - if @server.actual_suspension_reason
+ %p.suspensionBox__reason
+ Reason: #{@server.actual_suspension_reason}
+
+ .mailGraph.u-margin{:data => {:data => @graph_data.to_json}}
+ %ul.mailGraph__key
+ %li.mailGraph__key--in Incoming Messages
+ %li.mailGraph__key--out Outgoing Messages
+
+ .mailGraph__graph
+ %ul.mailGraph__labels
+ - if @graph_type == :hourly
+ %li #{@first_date.strftime("%A at %l%P")} →
+ %li Today at #{Time.now.strftime("%l%P")}
+ - else
+ %li #{@first_date.to_date.to_s(:long)} →
+ %li Today
+
+ .titleWithLinks.u-margin
+ %h2.titleWithLinks__title Recently processed e-mails
+ %ul.titleWithLinks__links
+ %li= link_to "View message queue", [:queue, organization, @server], :class => 'titleWithLinks__link'
+ %li= link_to "View full e-mail history", [:outgoing, organization, @server, :messages], :class => 'titleWithLinks__link'
+ = render 'messages/list', :messages => @messages
+
diff --git a/app/views/servers/spam.html.haml b/app/views/servers/spam.html.haml
new file mode 100644
index 0000000..9500d2f
--- /dev/null
+++ b/app/views/servers/spam.html.haml
@@ -0,0 +1,40 @@
+- page_title << @server.name
+- page_title << "Spam Handling"
+= render 'sidebar', :active_server => @server
+= render 'header', :active_nav => :settings
+= render 'settings_header', :active_nav => :spam
+.pageContent.pageContent--compact
+ %p.pageContent__intro.u-margin
+ Postal inspects all incoming messages for spam and other threats. Incoming messages
+ are assigned a score which represents how likely an e-mail is to be spam. From here
+ you can choose at which level you'd like to identify messages as spam.
+
+ = form_for [organization, @server], :remote => true do |f|
+ .u-margin
+ %p.pageContent__subTitle Incoming Spam Threshold
+ %p.pageContent__text.u-margin
+ The main spam threshold is what determines whether a message is spam or not. How incoming
+ messages that are detected as spam is determined by the route which the incoming message was
+ sent to. You can choose between marking the message as spam and sending it on to your endpoint,
+ putting it into quarantine (holding it until manually released) or just failing it.
+ %p= f.text_field :spam_threshold, :type => :range, :class => 'spamRange', :min => -10, :max => 25, :step => 0.5, :data => {:update => "js-spam-threshold-text"}
+ %p.spamRangeLabel Threshold is currently #{@server.spam_threshold}
+
+ .u-margin
+ %p.pageContent__subTitle Incoming Spam Failure Threshold
+ %p.pageContent__text.u-margin
+ Any messages which are over your spam failure threshold will failed immediately. This is used
+ to catch messages that we very sure at spam to avoid sending them needless around the place.
+ %p= f.text_field :spam_failure_threshold, :type => :range, :class => 'spamRange spamRange--hot', :min => 10, :max => 50, :step => 0.5, :data => {:update => "js-spam-failure-threshold-text"}
+ %p.spamRangeLabel Threshold is currently #{@server.spam_failure_threshold}
+
+ - if @server.outbound_spam_threshold
+ .u-margin
+ %p.pageContent__subTitle Outgoing Spam Threshold
+ %p.pageContent__text.u-margin
+ To prevent abuse of our services, we check outgoing messages to see whether they're likely to be
+ caught as spam by other providers. Messages that score higher than the threshold set by us will
+ not be passed through. If this limit needs adjusting, contact us for assistance.
+ %b The threshold for this server is currently #{@server.outbound_spam_threshold}.
+
+ %p= f.submit "Save Spam Thresholds", :class => "button button--positive js-form-submit"
diff --git a/app/views/sessions/begin_password_reset.html.haml b/app/views/sessions/begin_password_reset.html.haml
new file mode 100644
index 0000000..722a9d3
--- /dev/null
+++ b/app/views/sessions/begin_password_reset.html.haml
@@ -0,0 +1,16 @@
+- page_title << "Reset your password"
+.subPageBox__title
+ Reset your password
+= display_flash
+.subPageBox__content
+ %p.subPageBox__text
+ If you've forgotten your password, just enter your e-mail address below and we'll send you an email with a link which
+ will allow you to choose a new password.
+ = form_tag login_reset_path, :class => 'loginForm' do
+ = hidden_field_tag 'return_to', params[:return_to]
+ %p.loginForm__input= text_field_tag 'email_address', '', :class => 'input input--text input--onWhite', :placeholder => "Your e-mail address", :autofocus => true, :tabindex => 1
+ .loginForm__submit
+ %ul.loginForm__links
+ %li= link_to "Back to login", login_path(:return_to => params[:return_to])
+ %p= submit_tag "Continue", :class => 'button button--positive', :tabindex => 3
+
diff --git a/app/views/sessions/finish_password_reset.html.haml b/app/views/sessions/finish_password_reset.html.haml
new file mode 100644
index 0000000..c7ecd82
--- /dev/null
+++ b/app/views/sessions/finish_password_reset.html.haml
@@ -0,0 +1,20 @@
+- page_title << "Reset your password"
+.subPageBox__title
+ Choose a new password
+= display_flash
+
+.subPageBox__content
+ %p.subPageBox__text
+ If you've forgotten your password, just enter your e-mail address below and we'll send you an email with a link which
+ will allow you to choose a new password.
+ = form_tag '', :class => 'loginForm' do
+ = error_messages_for @user
+ = hidden_field_tag 'return_to', params[:return_to]
+ %p.loginForm__input= password_field_tag 'password', params[:password], :class => 'input input--text input--onWhite', :placeholder => "Choose a new password", :autofocus => true, :tabindex => 1
+ %p.loginForm__input= password_field_tag 'password_confirmation', params[:password_confirmation], :class => 'input input--text input--onWhite', :placeholder => "and enter it again to confirm", :tabindex => 2
+
+ .loginForm__submit
+ %ul.loginForm__links
+ %li= link_to "Back to login", login_path(:return_to => params[:return_to])
+ %p= submit_tag "Login", :class => 'button button--positive', :tabindex => 3
+
diff --git a/app/views/sessions/new.html.haml b/app/views/sessions/new.html.haml
new file mode 100644
index 0000000..9fdf12f
--- /dev/null
+++ b/app/views/sessions/new.html.haml
@@ -0,0 +1,19 @@
+- page_title << "Login"
+.subPageBox__title
+ Welcome to Postal
+= display_flash
+
+.subPageBox__content
+ = form_tag login_path, :class => 'loginForm' do
+ = hidden_field_tag 'return_to', params[:return_to]
+ - if params[:return_to] && params[:return_to] =~ /\/join\//
+ %p.loginForm__invite.warningBox.u-margin To accept your invitation you need to login to your account or create a new one. Choose from the options below to continue.
+
+ %p.loginForm__input= text_field_tag 'email_address', '', :type => 'email', :autocomplete => 'off', :spellcheck => 'false', :class => 'input input--text input--onWhite', :placeholder => "Your e-mail address", :autofocus => true, :tabindex => 1
+ %p.loginForm__input= password_field_tag 'password', '', :class => 'input input--text input--onWhite', :placeholder => "Your password", :tabindex => 2
+ .loginForm__submit
+ %ul.loginForm__links
+ %li= link_to "Forgotten your password?", login_reset_path(:return_to => params[:return_to])
+ %li= link_to "Create a new user", signup_path(:return_to => params[:return_to])
+ %p= submit_tag "Login", :class => 'button button--positive', :tabindex => 3
+
diff --git a/app/views/shared/_message_db_pagination.html.haml b/app/views/shared/_message_db_pagination.html.haml
new file mode 100644
index 0000000..07d762c
--- /dev/null
+++ b/app/views/shared/_message_db_pagination.html.haml
@@ -0,0 +1,10 @@
+.simplePagination
+ %p.simplePagination__previous
+ - if data[:page] > 1
+ = link_to "← Previous page".html_safe, request.params.merge(:page => data[:page] - 1), :class => 'simplePagination__link'
+ .simplePagination__current
+ %p.simplePagination__info Showing #{number_with_delimiter data[:records].size} of #{number_with_delimiter data[:total]} #{data[:total] == 1 ? name : name.pluralize}
+ %p Page #{data[:page]} of #{number_with_delimiter data[:total_pages]}
+ %p.simplePagination__next
+ - if data[:total_pages] > data[:page]
+ = link_to "Next page →".html_safe, request.params.merge(:page => data[:page] + 1), :class => 'simplePagination__link'
diff --git a/app/views/smtp_endpoints/_form.html.haml b/app/views/smtp_endpoints/_form.html.haml
new file mode 100644
index 0000000..686e545
--- /dev/null
+++ b/app/views/smtp_endpoints/_form.html.haml
@@ -0,0 +1,30 @@
+= form_for [organization, @server, @smtp_endpoint], :remote => true do |f|
+ = f.error_messages
+ %fieldset.fieldSet
+ .fieldSet__field
+ = f.label :name, :class => 'fieldSet__label'
+ .fieldSet__input= f.text_field :name, :autofocus => true, :class => 'input input--text'
+ .fieldSet__field
+ = f.label :hostname, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.text_field :hostname, :class => 'input input--text'
+ .fieldSet__field
+ = f.label :port, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.text_field :port, :class => 'input input--text', :placeholder => "25 (by default)"
+ .fieldSet__field
+ = f.label :ssl_mode, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.select :ssl_mode, SMTPEndpoint::SSL_MODES, {}, :class => 'input input--select'
+ %p.fieldSet__text
+ Choose what, if any, SSL mode you'd like to use when delivering mail to this mail server.
+ Be aware that any mail sent with no SSL is insecure and not protected in anyway.
+
+ .fieldSetSubmit.buttonSet
+ = f.submit @smtp_endpoint.new_record? ? "Create SMTP endpoint" : "Save SMTP endpoint", :class => 'button button--positive js-form-submit'
+ .fieldSetSubmit__delete
+ - if f.object.persisted?
+ = link_to "Delete SMTP endpoint", [organization, @server, @smtp_endpoint], :remote => true, :class => 'button button--danger', :method => :delete, :data => {:confirm => "Are you sure you wish to delete this SMTP endpoint?\n\r#{pluralize @smtp_endpoint.routes.size, 'route'} that uses this endpoint will also be deleted."}
+
+ = hidden_field_tag 'return_to', params[:return_to]
+ = hidden_field_tag 'return_notice', params[:return_notice]
diff --git a/app/views/smtp_endpoints/edit.html.haml b/app/views/smtp_endpoints/edit.html.haml
new file mode 100644
index 0000000..a5d882a
--- /dev/null
+++ b/app/views/smtp_endpoints/edit.html.haml
@@ -0,0 +1,11 @@
+- page_title << @server.name
+- page_title << "Routing"
+- page_title << "SMTP Endpoints"
+- page_title << "Edit"
+
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :routing
+= render 'routes/header', :active_nav => :smtp_endpoints
+.pageContent.pageContent--compact
+ = render 'form'
+
diff --git a/app/views/smtp_endpoints/index.html.haml b/app/views/smtp_endpoints/index.html.haml
new file mode 100644
index 0000000..ace91a2
--- /dev/null
+++ b/app/views/smtp_endpoints/index.html.haml
@@ -0,0 +1,36 @@
+- page_title << @server.name
+- page_title << "Routing"
+- page_title << "SMTP Endpoints"
+
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :routing
+= render 'routes/header', :active_nav => :smtp_endpoints
+.pageContent.pageContent--compact
+
+ - if @smtp_endpoints.empty?
+ .noData.noData--dog.noData--clean
+ %h2.noData__title Im-paws-ible! You've got no SMTP endpoints.
+ %p.noData__text
+ SMTP endpoints are other mail servers that you'd like incoming e-mails
+ to be passed onto. Once you've added some endpoints, you can route messages
+ to them by creating #{link_to 'routes', [organization, @server, :routes], :class => 'u-link'}.
+ %p.noData__button
+ = link_to "Add your first SMTP endpoint", [:new, organization, @server, :smtp_endpoint], :class => 'button button--positive'
+
+ - else
+
+ %ul.endpointList.u-margin
+ - for endpoint in @smtp_endpoints
+ %li.endpointList__item
+ = link_to [:edit, organization, @server, endpoint], :class => 'endpointList__link' do
+ .endpointList__main
+ %p.endpointList__name= endpoint.name
+ %p.endpointList__url= endpoint.hostname
+ %ul.endpointList__details
+ %li.endpointList__detailItem
+ - if endpoint.last_used_at
+ Last used #{distance_of_time_in_words_to_now endpoint.last_used_at} ago
+ - else
+ Not used yet
+
+ %p.u-center= link_to "Add another SMTP endpoint", [:new, organization, @server, :smtp_endpoint], :class => 'button button--positive'
diff --git a/app/views/smtp_endpoints/new.html.haml b/app/views/smtp_endpoints/new.html.haml
new file mode 100644
index 0000000..79c874c
--- /dev/null
+++ b/app/views/smtp_endpoints/new.html.haml
@@ -0,0 +1,11 @@
+- page_title << @server.name
+- page_title << "Routing"
+- page_title << "SMTP Endpoints"
+- page_title << "New"
+
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :routing
+= render 'routes/header', :active_nav => :smtp_endpoints
+.pageContent.pageContent--compact
+ = render 'form'
+
diff --git a/app/views/track_domains/_form.html.haml b/app/views/track_domains/_form.html.haml
new file mode 100644
index 0000000..c59de7e
--- /dev/null
+++ b/app/views/track_domains/_form.html.haml
@@ -0,0 +1,50 @@
+= form_for [organization, @server, @track_domain], :remote => true do |f|
+ = f.error_messages
+ %fieldset.fieldSet
+ .fieldSet__field
+ = f.label :name, "Domain", :class => 'fieldSet__label'
+ .fieldSet__input
+ .routeNameInput
+ = f.text_field :name, :autofocus => true, :class => 'input input--text routeNameInput__name', :disabled => @track_domain.persisted?
+ %span.routeNameInput__at .
+ = f.select :domain_id, domain_options_for_select(@server, @track_domain.domain), {}, :class => 'input input--select routeNameInput__domain', :disabled => @track_domain.persisted?
+ %p.fieldSet__text
+ This is the domain that requests for tracked links will be directed through when you use click tracking. We recommend using something like
+ click.yourdomain.com. You will need to a CNAME record to point to #{Postal.config.dns.track_domain} for this once you've added it.
+
+ .fieldSet__field
+ = f.label :ssl_enabled, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.select :ssl_enabled, [["Yes - use SSL for tracking whenever possible", true], ["No - never use SSL for tracking", false]], {}, :class => 'input input--select'
+ %p.fieldSet__text
+ If enabled, we'll try to remove the replies/signatures from the plain body and send them seperately to the rest of the body.
+ This is useful if you just want to see the latest message in a thread.
+
+ .fieldSet__field
+ = f.label :track_loads, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.select :track_loads, [["Yes - track when HTML e-mails are opened", true], ["No - don't track when HTML e-mails are opened", false]], {}, :class => 'input input--select'
+ %p.fieldSet__text
+ If enabled, we'll insert a 1px image into the footer of any HTML e-mails. When this image is loaded, we'll log
+ this as a view and notify you with a webhook.
+
+ .fieldSet__field
+ = f.label :track_clicks, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.select :track_clicks, [["Yes - track when links are clicked", true], ["No - don't track when links are clicked", false]], {}, :class => 'input input--select'
+ %p.fieldSet__text
+ If enabled, we'll rewrite URLs in your outbound messages to go via this domain. You'll receive a webhook when
+ someone clicks one of your links and it will be displayed in the web interface.
+
+ .fieldSet__field
+ = f.label :excluded_click_domains, "Domains excluded from tracking", :class => 'fieldSet__label'
+ .fieldSet__input
+ ~ f.text_area :excluded_click_domains, :class => 'input input--smallArea'
+ %p.fieldSet__text
+ This is a list of domains of links that you don't wish to be tracked. When click tracking is enabled,
+ you can provide a list (one domain per line) for links that you don't wish to be tracked.
+
+
+ .fieldSetSubmit.buttonSet
+ = f.submit @track_domain.new_record? ? "Create Track Domain" : "Save Track Domain", :class => 'button button--positive js-form-submit'
+
diff --git a/app/views/track_domains/edit.html.haml b/app/views/track_domains/edit.html.haml
new file mode 100644
index 0000000..51db565
--- /dev/null
+++ b/app/views/track_domains/edit.html.haml
@@ -0,0 +1,9 @@
+- page_title << @server.name
+- page_title << "Tracking Domains"
+- page_title << "Edit Tracking Domain Setting"
+
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :domains
+= render 'domains/nav', :active_nav => :track_domains
+.pageContent.pageContent--compact
+ = render 'form'
diff --git a/app/views/track_domains/index.html.haml b/app/views/track_domains/index.html.haml
new file mode 100644
index 0000000..c22b1f5
--- /dev/null
+++ b/app/views/track_domains/index.html.haml
@@ -0,0 +1,47 @@
+- page_title << @server.name
+- page_title << "Tracking Domains"
+
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :domains
+= render 'domains/nav', :active_nav => :track_domains
+
+.pageContent.pageContent--compact
+
+ - if @track_domains.empty?
+ .noData.noData--fox.noData--clean
+ %h2.noData__title You haven't set up any tracking domains yet.
+ %p.noData__text
+ To use Postal's open & click tracking, you need to configure a domain that links will be re-written to use. Enable
+ message tracking by adding a sutiable tracking domain for your outbound e-mails.
+ %p.noData__button= link_to "Add a custom tracking domain", [:new, organization, @server, :track_domain], :class => "button button--positive"
+
+ - else
+ %ul.domainList.u-margin
+ - for track_domain in @track_domains
+ %li.domainList__item
+ .domainList__details
+ %p.domainList__name
+ = link_to track_domain.full_name, [:edit, organization, @server, track_domain]
+ %ul.domainList__checks
+ - if track_domain.dns_status == 'OK'
+ %li.domainList__check.domainList__check--ok CNAME configured correctly
+ - elsif track_domain.dns_status.nil?
+ %li.domainList__check.domainList__check--neutral-cross CNAME/DNS not checked yet
+ - else
+ %li.domainList__check.domainList__check--warning{:title => track_domain.dns_error} CNAME not configured correctly
+
+ - if track_domain.ssl_enabled?
+ - if track_domain.has_ssl?
+ %li.domainList__check.domainList__check--ok= link_to "SSL enabled", [:toggle_ssl, organization, @server, track_domain], :remote => true, :method => :post
+ - else
+ %li.domainList__check.domainList__check--neutral= link_to "SSL setup in progress", [:toggle_ssl, organization, @server, track_domain], :remote => true, :method => :post
+ - else
+ %li.domainList__check.domainList__check--neutral-cross= link_to "SSL disabled", [:toggle_ssl, organization, @server, track_domain], :remote => true, :method => :post
+
+ %ul.domainList__properties
+ %li.domainList__links
+ = link_to "Settings", [:edit, organization, @server, track_domain]
+ = link_to "Check DNS", [:check, organization, @server, track_domain], :remote => true, :method => :post, :data => {:disable_with => "Checking..."}
+ = link_to "Delete", [organization, @server, track_domain], :remote => true, :method => :delete, :data => {:confirm => "Are you sure you wish to remove this domain?", :disable_with => "Deleting..."}, :class => 'domainList__delete'
+
+ %p.u-center= link_to "Add new track domain", [:new, organization, @server, :track_domain], :class => "button button--positive"
diff --git a/app/views/track_domains/new.html.haml b/app/views/track_domains/new.html.haml
new file mode 100644
index 0000000..3b075f6
--- /dev/null
+++ b/app/views/track_domains/new.html.haml
@@ -0,0 +1,9 @@
+- page_title << @server.name
+- page_title << "Tracking Domains"
+- page_title << "New Tracking Domain"
+
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :domains
+= render 'domains/nav', :active_nav => :track_domains
+.pageContent.pageContent--compact
+ = render 'form'
diff --git a/app/views/user/edit.html.haml b/app/views/user/edit.html.haml
new file mode 100644
index 0000000..53d2fa2
--- /dev/null
+++ b/app/views/user/edit.html.haml
@@ -0,0 +1,55 @@
+- page_title << "My Settings"
+.pageHeader
+ %h1.pageHeader__title
+ My Settings
+.pageContent.pageContent--compact
+ = form_for @user, :url => settings_path, :remote => true do |f|
+ = f.error_messages
+ %fieldset.fieldSet
+ .fieldSet__field
+ = label_tag :password, 'Your Password', :class => 'fieldSet__label'
+ .fieldSet__input
+ = password_field_tag :password, params[:password], :autofocus => @password_correct.nil?, :disabled => @password_correct, :class => 'input input--text', :placeholder => "Enter your current password to change your details"
+ - if @password_correct
+ = hidden_field_tag :password, params[:password]
+ %p.fieldSet__text
+ In order to protect your account, you need to enter your current password in the field above
+ to authenticate the change of your details.
+
+ .fieldSet__title
+ Your details
+
+ .fieldSet__field
+ = f.label :first_name, "Name", :class => 'fieldSet__label'
+ .fieldSet__input
+ .inputPair
+ = f.text_field :first_name, :class => 'input input--text', :autofocus => @password_correct
+ = f.text_field :last_name, :class => 'input input--text'
+ .fieldSet__field
+ = f.label :email_address, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.text_field :email_address, :class => 'input input--text'
+ %p.fieldSet__text
+ If you change your e-mail address, you'll need to verify that you own the new one before
+ you can continue using your account.
+
+ .fieldSet__field
+ = f.label :time_zone, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.time_zone_select :time_zone, [], {}, :class => 'input input--select'
+ %p.fieldSet__text
+ Choose the time zone that you'd like times to be displayed to you when you use our
+ web interface. By default, times are displayed in UTC.
+
+ .fieldSet__title
+ Change your password?
+ .fieldSet__field
+ = f.label :password, "New Password", :class => 'fieldSet__label'
+ .fieldSet__input
+ .inputPair
+ = f.password_field :password, :class => 'input input--text', :placeholder => "•••••••••••", :value => @user.password
+ = f.password_field :password_confirmation, :class => 'input input--text', :placeholder => "and confirm it", :value => @user.password_confirmation
+
+
+ %p.fieldSetSubmit.buttonSet
+ = f.submit "Save Settings", :class => 'button button--positive js-form-submit'
diff --git a/app/views/user/join.html.haml b/app/views/user/join.html.haml
new file mode 100644
index 0000000..910b53c
--- /dev/null
+++ b/app/views/user/join.html.haml
@@ -0,0 +1,16 @@
+- page_title << "Join Organization"
+.pageHeader
+ %h1.pageHeader__title
+ Join Organization
+.pageContent.pageContent--compact
+ %h2.pageContent__intro.u-margin
+ Welcome to Postal.
+ - if @organizations.size == 1
+ You've been invited to join the #{@organizations.first.name} organization. Once accepted, you'll be able to
+ access this organization's mail servers.
+ - else
+ You've been invited to join an organization
+
+ %p.buttonSet
+ = link_to "Accept " + (@organizations.size == 1 ? "Invitation" : "Invitations"), '', :class => "button button--positive", :remote => true, :method => :post
+ = link_to "Reject " + (@organizations.size == 1 ? "Invitation" : "Invitations"), '', :class => "button button--danger", :remote => true, :method => :delete
diff --git a/app/views/user/new.html.haml b/app/views/user/new.html.haml
new file mode 100644
index 0000000..8f0bc6a
--- /dev/null
+++ b/app/views/user/new.html.haml
@@ -0,0 +1,39 @@
+- @wide = true
+- page_title << "Signup"
+.subPageBox__title
+ Create your own Postal account
+= display_flash
+
+.subPageBox__content
+ %p.subPageBox__text
+ To create an account, just enter your details below and you'll be on your way.
+ Be sure to enter a valid e-mail address because we'll send you a verification
+ e-mail as part of the signup process.
+ .signupForm
+ = form_for @user, :url => signup_path do |f|
+ = hidden_field_tag 'return_to', params[:return_to]
+ = f.error_messages
+ .fieldSet.fieldSet--compact.u-margin
+ .fieldSet__fieldPair
+ .fieldSet__field
+ = f.label :first_name, :class => 'fieldSet__label'
+ .fieldSet__input= f.text_field :first_name, :class => 'input input--text input--onWhite', :autofocus => true
+ .fieldSet__field
+ = f.label :last_name, :class => 'fieldSet__label'
+ .fieldSet__input= f.text_field :last_name, :class => 'input input--text input--onWhite'
+ .fieldSet__field
+ = f.label :email_address, :class => 'fieldSet__label'
+ .fieldSet__input= f.text_field :email_address, :class => 'input input--text input--onWhite'
+ .fieldSet__fieldPair
+ .fieldSet__field
+ = f.label :password, :class => 'fieldSet__label'
+ .fieldSet__input= f.password_field :password, :class => 'input input--text input--onWhite', :placeholder => '•••••••••••'
+ .fieldSet__field
+ = f.label :password_confirmation, " ".html_safe, :class => 'fieldSet__label'
+ .fieldSet__input= f.password_field :password_confirmation, :class => 'input input--text input--onWhite', :placeholder => '•••••••••••'
+
+ .loginForm__submit
+ %ul.loginForm__links
+ %li= link_to "Back to login", login_path(:return_to => params[:return_to])
+ %p= submit_tag "Create Account", :class => 'button button--positive', :tabindex => 3
+
diff --git a/app/views/user/verify.html.haml b/app/views/user/verify.html.haml
new file mode 100644
index 0000000..dbcdcaa
--- /dev/null
+++ b/app/views/user/verify.html.haml
@@ -0,0 +1,18 @@
+- page_title << "Verify your e-mail address"
+.pageHeader
+ %h1.pageHeader__title
+ Please verify your e-mail address
+.pageContent.pageContent--compact
+ %h2.pageContent__intro.u-margin
+ We need to verify that you're the owner of the e-mail address on your account. It is currently #{current_user.email_address}. To
+ do this, we've sent you an e-mail with a 6-digit code. Please check your e-mail and enter the code within in the box below.
+ %p.pageContent__text
+ If you need to change your e-mail address, you can do this in from your #{link_to 'settings page', settings_path, :class => "u-link"}.
+ If you haven't received the e-mail, you can #{link_to 'click here to resend it', '', :class => "u-link"}.
+ = form_tag request.fullpath, :remote => true do
+ = hidden_field_tag 'return_to', params[:return_to]
+ %p.u-margin
+ = text_field_tag "code", params[:code], :autofocus => true, :class => 'input input--text js-multibox'
+ .buttonSet.u-center
+ = submit_tag "Verify my e-mail address", :class => 'button button--positive js-form-submit'
+
diff --git a/app/views/users/_form.html.haml b/app/views/users/_form.html.haml
new file mode 100644
index 0000000..b512d3a
--- /dev/null
+++ b/app/views/users/_form.html.haml
@@ -0,0 +1,24 @@
+= form_for @organization_user, :url => @organization_user.new_record? ? organization_users_path(organization) : organization_user_path(organization, @organization_user.user), :remote => true do |f|
+ = f.error_messages
+ = hidden_field_tag 'invite', params[:invite]
+ %fieldset.fieldSet
+ .fieldSet__field
+ = f.label :email_address, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.text_field :email_address, :autofocus => true, :class => 'input input--text', :disabled => @organization_user.persisted?
+ .fieldSet__title
+ What level of access do you wish to grant?
+ .fieldSet__field
+ = f.label :admin, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.select :admin, [["No - do not grant admin access", false], ["Yes - grant admin access", true]], {},:class => 'input input--select'
+ %p.fieldSet__text
+ Users who have admin access will be permitted to manage the organization as if they had the same
+ access as the organization owner. This includes managing users, creating & deleting mail servers,
+ and even removing the account. This level of access should granted carefully.
+ Regardless of which level you select, this user will have access to all servers within the organization.
+
+ .fieldSetSubmit.buttonSet
+ = submit_tag @organization_user.new_record? ? "Add User" : "Save User", :class => 'button button--positive js-form-submit'
+ .fieldSetSubmit__delete
+ = link_to "Back to user list", [organization, :users], :class => 'button button--neutral'
diff --git a/app/views/users/edit.html.haml b/app/views/users/edit.html.haml
new file mode 100644
index 0000000..6a6a612
--- /dev/null
+++ b/app/views/users/edit.html.haml
@@ -0,0 +1,15 @@
+- page_title << "Users"
+- page_title << "Permissions"
+.pageHeader
+ %h1.pageHeader__title
+ %span.pageHeader__titlePrevious
+ = @organization.name
+ →
+ Users
+ →
+ Edit user permissions
+
+= render 'organizations/nav', :active_nav => :users
+.pageContent.pageContent--compact
+ = render 'form'
+
diff --git a/app/views/users/index.html.haml b/app/views/users/index.html.haml
new file mode 100644
index 0000000..96ce9ba
--- /dev/null
+++ b/app/views/users/index.html.haml
@@ -0,0 +1,46 @@
+- page_title << "Users"
+.pageHeader
+ %h1.pageHeader__title
+ %span.pageHeader__titlePrevious
+ = @organization.name
+ →
+ Users
+
+= render 'organizations/nav', :active_nav => :users
+.pageContent.pageContent--compact
+ %p.pageContent__intro.u-margin
+ You can share access to your organization with other people by adding them
+ here. They'll need to create their own account first and then you'll be able
+ to add them to your organization by entering their e-mail address.
+
+ %ul.userList.u-margin
+ - for user in @users
+ %li.userList__item
+ = image_tag user.user.avatar_url, :class => 'userList__avatar'
+ .userList__details
+ %p.userList__name
+ = user.user.name
+ - if user.user == organization.owner
+ %span.userList__owner.label Owner
+ - elsif user.admin?
+ %span.userList__admin.label Admin
+ %p.userList__email= user.user.email_address
+ %ul.userList__actions
+ - if organization.owner != user.user
+ %li= link_to "Edit permissions", [:edit, organization, user.user]
+ - if organization.owner == current_user
+ %li= link_to "Make owner", [:make_owner, organization, user.user], :method => :post, :data => {:confirm => "Are you sure you wish to make #{user.user.name} the owner of this organization? They will be granted full admin access. You won't be able to change this back.", :disable_with => "Promoting..."}, :remote => true
+ %li= link_to "Revoke access", [organization, user.user], :method => :delete, :data => {:confirm => "Are you sure you wish to revoke #{user.user.name}'s access to the organization?", :disable_with => "Deleting..."}, :remote => true, :class => 'userList__revoke'
+ - for user in @pending_users
+ %li.userList__item
+ = image_tag user.user.avatar_url, :class => 'userList__avatar'
+ .userList__details
+ %p.userList__name
+ = user.user.email_address
+ %span.userList__pending.label Pending
+ %ul.userList__actions
+ %li= link_to "Edit permissions", edit_organization_user_path(organization, user.user, :invite => 1)
+ %li= link_to "Cancel invitation", organization_user_path(organization, user.user, :invite => 1), :method => :delete, :data => {:confirm => "Are you sure you wish to cancel this invitation?", :disable_with => "Deleting..."}, :remote => true, :class => 'userList__revoke'
+
+
+ %p.u-center= link_to "Invite a new user", [:new, organization, :user], :class => 'button button--positive'
diff --git a/app/views/users/new.html.haml b/app/views/users/new.html.haml
new file mode 100644
index 0000000..c075224
--- /dev/null
+++ b/app/views/users/new.html.haml
@@ -0,0 +1,19 @@
+- page_title << "Users"
+- page_title << "Add"
+.pageHeader
+ %h1.pageHeader__title
+ %span.pageHeader__titlePrevious
+ = @organization.name
+ →
+ Users
+ →
+ Add user
+
+= render 'organizations/nav', :active_nav => :users
+.pageContent.pageContent--compact
+ %p.pageContent__intro.u-margin
+ To add someone to your organization enter their e-mail address below. If
+ they don't already have a Postal account, they'll need to create one
+ before you can add them to your account.
+ = render 'form'
+
diff --git a/app/views/webhooks/_form.html.haml b/app/views/webhooks/_form.html.haml
new file mode 100644
index 0000000..fdd0f10
--- /dev/null
+++ b/app/views/webhooks/_form.html.haml
@@ -0,0 +1,44 @@
+= form_for [organization, @server, @webhook], :remote => true do |f|
+ = f.error_messages
+ %fieldset.fieldSet
+ .fieldSet__field
+ = f.label :name, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.text_field :name, :autofocus => true, :class => 'input input--text'
+ %p.fieldSet__text
+ Enter a name to describe this webhook. This is used so you can identify this webhook later in the web interface.
+
+ .fieldSet__field
+ = f.label :url, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.text_field :url, :class => 'input input--text'
+ %p.fieldSet__text
+ Enter the URL that you'd like us to send requests to. All requests will be POST requests with
+ a JSON-encoded payload in the body of the request.
+ .fieldSet__field
+ = f.label :enabled, :class => 'fieldSet__label'
+ .fieldSet__input
+ = f.select :enabled, [["Yes - send requests to this webhook", true], ["No - do not send requests at the moment", false]], {},:class => 'input input--select'
+ %p.fieldSet__text
+ You can enable or disable this webhook without fully removing it from the system. If there are any outstanding
+ webhook deliveries, they will still be completed even if disabled.
+
+ .fieldSet__field
+ = f.label :all_events, 'Events', :class => 'fieldSet__label'
+ .fieldSet__input
+ = hidden_field_tag 'webhook[events][]'
+ = f.select :all_events, [["Yes - send all events to this URL", true], ["No - I'll choose which requests to send", false]], {},:class => 'input input--select fieldSet__checkboxListAfter js-checkbox-list-toggle'
+ %ul.checkboxList{:class => [@webhook.all_events? ? 'is-hidden' : '']}
+ - for event in WebhookEvent::EVENTS
+ %li.checkboxList__item
+ .checkboxList__checkbox= check_box_tag "webhook[events][]", event, @webhook.events.include?(event), :id => "event_#{event}"
+ .checkboxList__label
+ = label_tag "event_#{event}", event, :class => 'checkboxList__actualLabel checkboxList__devEvent'
+ %p.checkBoxList__text= t("webhook_events.#{event.underscore}")
+
+ .fieldSetSubmit.buttonSet
+ = f.submit @webhook.new_record? ? "Create Webhook" : "Save Webhook", :class => 'button button--positive js-form-submit'
+ .fieldSetSubmit__delete
+ - if f.object.persisted?
+ = link_to "Delete Webhook", [organization, @server, @webhook], :remote => true, :class => 'button button--danger', :method => :delete, :data => {:confirm => "Are you sure you wish to delete this webhook?"}
+
diff --git a/app/views/webhooks/_header.html.haml b/app/views/webhooks/_header.html.haml
new file mode 100644
index 0000000..9714c3b
--- /dev/null
+++ b/app/views/webhooks/_header.html.haml
@@ -0,0 +1,5 @@
+.navBar.navBar--secondary
+ %ul
+ %li.navBar__item= link_to "Manage Webhooks", [organization, @server, :webhooks], :class => ['navBar__link', active_nav == :webhooks ? 'is-active' : '']
+ %li.navBar__item= link_to "View History", [:history, organization, @server, :webhooks], :class => ['navBar__link', active_nav == :history ? 'is-active' : '']
+
diff --git a/app/views/webhooks/edit.html.haml b/app/views/webhooks/edit.html.haml
new file mode 100644
index 0000000..90faca9
--- /dev/null
+++ b/app/views/webhooks/edit.html.haml
@@ -0,0 +1,10 @@
+- page_title << @server.name
+- page_title << "Webhooks"
+- page_title << "Edit"
+
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :webhooks
+= render 'header', :active_nav => :webhooks
+.pageContent.pageContent--compact
+ = render 'form'
+
diff --git a/app/views/webhooks/history.html.haml b/app/views/webhooks/history.html.haml
new file mode 100644
index 0000000..07300bc
--- /dev/null
+++ b/app/views/webhooks/history.html.haml
@@ -0,0 +1,32 @@
+- page_title << @server.name
+- page_title << "Webhooks"
+- page_title << "History"
+
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :webhooks
+= render 'header', :active_nav => :history
+
+.pageContent.pageContent--compact
+ - if @requests[:records].empty?
+ .noData.noData--clean.noData--cat3
+ %h2.noData__title No webhook requests recorded.
+ %p.noData__text
+ This page shows the last 10 days worth of webhook requests that have been sent by Postal. This page will
+ populate automatically as webhooks are dispatched.
+
+ - else
+ %p.pageContent__intro.u-margin
+ This page shows a list of all webhook requests which have been sent for this server. These are kept for 10 days before being
+ removed. Click on a request for additional information.
+ %ul.webhookRequestList
+ - for req in @requests[:records]
+ %li.webhookRequestList__item
+ = link_to history_request_organization_server_webhooks_path(organization, @server, req.uuid), :class => 'webhookRequestList__link' do
+ .webhookRequestList__top
+ %p.webhookRequestList__status
+ %span.label{:class => "label--http-status-#{req.status_code.to_s[0,1]}"}= req.status_code
+ %p.webhookRequestList__time= req.timestamp.strftime("%d/%m/%Y at %H:%M:%S")
+ %p.webhookRequestList__event= req.event
+ %p.webhookRequestList__url= req.url || "Unknown"
+
+ = render 'shared/message_db_pagination', :data => @requests, :name => 'request'
diff --git a/app/views/webhooks/history_request.html.haml b/app/views/webhooks/history_request.html.haml
new file mode 100644
index 0000000..86eaa1d
--- /dev/null
+++ b/app/views/webhooks/history_request.html.haml
@@ -0,0 +1,34 @@
+- page_title << @server.name
+- page_title << "Webhooks"
+- page_title << "History"
+
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :webhooks
+= render 'header', :active_nav => :history
+
+.pageContent.pageContent--compact
+ %dl.pageContent__definitions.u-margin
+ %dt URL
+ %dd= @req.url
+ %dt Event
+ %dd= @req.event
+ %dt UUID
+ %dd= @req.uuid
+
+ %dt Timestamp
+ %dd= @req.timestamp.strftime("%d/%m/%Y at %H:%M:%S")
+ %dt HTTP Status Code
+ %dd
+ %span.label.label--large{:class => "label--http-status-#{@req.status_code.to_s[0,1]}"}= @req.status_code
+
+ %dt Attempt
+ %dd
+ = @req.attempt
+ - if @req.will_retry?
+ (will be retried)
+
+ %p.pageContent__title Payload
+ %pre.codeBlock.u-margin.codeBlock--whitespace~ preserve @req.pretty_payload
+ %p.pageContent__title Response Body
+ %pre.codeBlock.u-margin= @req.body
+ %p.u-margin= link_to "Back to history", [:history, organization, @server, :webhooks], :class => "button button--neutral"
diff --git a/app/views/webhooks/index.html.haml b/app/views/webhooks/index.html.haml
new file mode 100644
index 0000000..6801279
--- /dev/null
+++ b/app/views/webhooks/index.html.haml
@@ -0,0 +1,35 @@
+- page_title << @server.name
+- page_title << "Webhooks"
+
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :webhooks
+= render 'header', :active_nav => :webhooks
+.pageContent.pageContent--compact
+ - if @webhooks.empty?
+ .noData.noData--clean.noData--lion
+ %h2.noData__title You've got no webhooks. We're not lion.
+ %p.noData__text
+ You haven't added any webhooks for this server yet. A webhook enables your web
+ application to be notified when certain events occur in the lifecycle of the mail server.
+ %p.noData__button= link_to "Add your first webhook", [:new, organization, @server, :webhook], :class => "button button--positive"
+ - else
+ %ul.webhookList.u-margin
+ - for webhook in @webhooks
+ %li.webhookList__item
+ .webhookList__top
+ %p.webhookList__name= link_to webhook.name, [:edit, organization, @server, webhook]
+ %p.webhookList__labels
+ - if webhook.enabled?
+ %span.label.label--green Enabled
+ - else
+ %span.label.label--red Disabled
+ .webhookList__bottom
+ %p.webhookList__usageTime
+ - if webhook.last_used_at
+ Last sent request #{distance_of_time_in_words_to_now webhook.last_used_at}.
+ - else
+ Not used yet.
+ %ul.webhookList__links
+ %li.webhookList__link= link_to "Edit Webhook", [:edit, organization, @server, webhook]
+
+ %p.u-center= link_to "Add another webhook", [:new, organization, @server, :webhook], :class => 'button button--positive'
diff --git a/app/views/webhooks/new.html.haml b/app/views/webhooks/new.html.haml
new file mode 100644
index 0000000..0533dc9
--- /dev/null
+++ b/app/views/webhooks/new.html.haml
@@ -0,0 +1,10 @@
+- page_title << @server.name
+- page_title << "Webhooks"
+- page_title << "New"
+
+= render 'servers/sidebar', :active_server => @server
+= render 'servers/header', :active_nav => :webhooks
+= render 'header', :active_nav => :webhooks
+.pageContent.pageContent--compact
+ = render 'form'
+
diff --git a/bin/bundle b/bin/bundle
new file mode 100755
index 0000000..66e9889
--- /dev/null
+++ b/bin/bundle
@@ -0,0 +1,3 @@
+#!/usr/bin/env ruby
+ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__)
+load Gem.bin_path('bundler', 'bundle')
diff --git a/bin/rails b/bin/rails
new file mode 100755
index 0000000..0739660
--- /dev/null
+++ b/bin/rails
@@ -0,0 +1,4 @@
+#!/usr/bin/env ruby
+APP_PATH = File.expand_path('../config/application', __dir__)
+require_relative '../config/boot'
+require 'rails/commands'
diff --git a/bin/rake b/bin/rake
new file mode 100755
index 0000000..1724048
--- /dev/null
+++ b/bin/rake
@@ -0,0 +1,4 @@
+#!/usr/bin/env ruby
+require_relative '../config/boot'
+require 'rake'
+Rake.application.run
diff --git a/bin/setup b/bin/setup
new file mode 100755
index 0000000..e620b4d
--- /dev/null
+++ b/bin/setup
@@ -0,0 +1,34 @@
+#!/usr/bin/env ruby
+require 'pathname'
+require 'fileutils'
+include FileUtils
+
+# path to your application root.
+APP_ROOT = Pathname.new File.expand_path('../../', __FILE__)
+
+def system!(*args)
+ system(*args) || abort("\n== Command #{args} failed ==")
+end
+
+chdir APP_ROOT do
+ # This script is a starting point to setup your application.
+ # Add necessary setup steps to this file.
+
+ puts '== Installing dependencies =='
+ system! 'gem install bundler --conservative'
+ system('bundle check') || system!('bundle install')
+
+ # puts "\n== Copying sample files =="
+ # unless File.exist?('config/database.yml')
+ # cp 'config/database.yml.sample', 'config/database.yml'
+ # end
+
+ puts "\n== Preparing database =="
+ system! 'bin/rails db:setup'
+
+ puts "\n== Removing old logs and tempfiles =="
+ system! 'bin/rails log:clear tmp:clear'
+
+ puts "\n== Restarting application server =="
+ system! 'bin/rails restart'
+end
diff --git a/bin/update b/bin/update
new file mode 100755
index 0000000..a8e4462
--- /dev/null
+++ b/bin/update
@@ -0,0 +1,29 @@
+#!/usr/bin/env ruby
+require 'pathname'
+require 'fileutils'
+include FileUtils
+
+# path to your application root.
+APP_ROOT = Pathname.new File.expand_path('../../', __FILE__)
+
+def system!(*args)
+ system(*args) || abort("\n== Command #{args} failed ==")
+end
+
+chdir APP_ROOT do
+ # This script is a way to update your development environment automatically.
+ # Add necessary update steps to this file.
+
+ puts '== Installing dependencies =='
+ system! 'gem install bundler --conservative'
+ system('bundle check') || system!('bundle install')
+
+ puts "\n== Updating database =="
+ system! 'bin/rails db:migrate'
+
+ puts "\n== Removing old logs and tempfiles =="
+ system! 'bin/rails log:clear tmp:clear'
+
+ puts "\n== Restarting application server =="
+ system! 'bin/rails restart'
+end
diff --git a/config.ru b/config.ru
new file mode 100644
index 0000000..565a46c
--- /dev/null
+++ b/config.ru
@@ -0,0 +1,5 @@
+# This file is used by Rack-based servers to start the application.
+
+require_relative 'config/environment'
+$0="[postal] #{ENV['PROC_NAME']}"
+run Rails.application
diff --git a/config/application.rb b/config/application.rb
new file mode 100644
index 0000000..af1f049
--- /dev/null
+++ b/config/application.rb
@@ -0,0 +1,32 @@
+require_relative 'boot'
+
+require "rails"
+require "active_model/railtie"
+require "active_record/railtie"
+require "action_controller/railtie"
+require "action_mailer/railtie"
+require "action_view/railtie"
+require "sprockets/railtie"
+
+# Require the gems listed in Gemfile, including any gems
+# you've limited to :test, :development, or :production.
+Bundler.require(*Rails.groups)
+
+module Postal
+ class Application < Rails::Application
+ # Disable most generators
+ config.generators do |g|
+ g.orm :active_record
+ g.test_framework false
+ g.stylesheets false
+ g.javascripts false
+ g.helper false
+ end
+
+ # Include from lib
+ config.eager_load_paths += %W(#{config.root}/lib #{config.root}/app/jobs)
+
+ # Disable field_with_errors
+ config.action_view.field_error_proc = Proc.new { |t, i| t }
+ end
+end
diff --git a/config/boot.rb b/config/boot.rb
new file mode 100644
index 0000000..8f801a8
--- /dev/null
+++ b/config/boot.rb
@@ -0,0 +1,16 @@
+ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
+
+$stdout.sync = true
+$stderr.sync = true
+
+require 'bundler/setup' # Set up gems listed in the Gemfile.
+
+require_relative '../lib/postal/config'
+Postal.check_config!
+
+ENV['DATABASE_URL'] = Postal.database_url
+ENV['RAILS_ENV'] = Postal.config.rails&.environment || 'development'
+if ENV['PROC_NAME']
+ $0="[postal] #{ENV['PROC_NAME']}"
+end
+
diff --git a/config/cron.rb b/config/cron.rb
new file mode 100644
index 0000000..ec71073
--- /dev/null
+++ b/config/cron.rb
@@ -0,0 +1,28 @@
+module Clockwork
+
+ configure do |config|
+ config[:tz] = 'UTC'
+ config[:logger] = Postal.logger_for(:cron)
+ end
+
+ every 1.minute, 'every-1-minutes' do
+ RequeueWebhooksJob.queue(:main)
+ SendNotificationsJob.queue(:main)
+ end
+
+ every 1.hour, 'every-hour', :at => ['**:15'] do
+ CheckAllDNSJob.queue(:main)
+ ExpireHeldMessagesJob.queue(:main)
+ CleanupAuthieSessionsJob.queue(:main)
+ end
+
+ every 1.hour, 'every-hour', :at => ['**:45'] do
+ PruneWebhookRequestsJob.queue(:main)
+ RenewTrackCertificatesJob.queue(:main)
+ end
+
+ every 1.day, 'every-day', :at => ['03:00'] do
+ ProcessMessageRetentionJob.queue(:main)
+ end
+
+end
diff --git a/config/environment.rb b/config/environment.rb
new file mode 100644
index 0000000..426333b
--- /dev/null
+++ b/config/environment.rb
@@ -0,0 +1,5 @@
+# Load the Rails application.
+require_relative 'application'
+
+# Initialize the Rails application.
+Rails.application.initialize!
diff --git a/config/environments/development.rb b/config/environments/development.rb
new file mode 100644
index 0000000..e5a88d7
--- /dev/null
+++ b/config/environments/development.rb
@@ -0,0 +1,54 @@
+Rails.application.configure do
+ # Settings specified here will take precedence over those in config/application.rb.
+
+ # In the development environment your application's code is reloaded on
+ # every request. This slows down response time but is perfect for development
+ # since you don't have to restart the web server when you make code changes.
+ config.cache_classes = false
+
+ # Do not eager load code on boot.
+ config.eager_load = false
+
+ # Show full error reports.
+ config.consider_all_requests_local = true
+
+ # Enable/disable caching. By default caching is disabled.
+ if Rails.root.join('tmp/caching-dev.txt').exist?
+ config.action_controller.perform_caching = true
+
+ config.cache_store = :memory_store
+ config.public_file_server.headers = {
+ 'Cache-Control' => 'public, max-age=172800'
+ }
+ else
+ config.action_controller.perform_caching = false
+
+ config.cache_store = :null_store
+ end
+
+ # Don't care if the mailer can't send.
+ config.action_mailer.raise_delivery_errors = false
+
+ config.action_mailer.perform_caching = false
+
+ # Print deprecation notices to the Rails logger.
+ config.active_support.deprecation = :log
+
+ # Raise an error on page load if there are pending migrations.
+ config.active_record.migration_error = :page_load
+
+ # Debug mode disables concatenation and preprocessing of assets.
+ # This option may cause significant delays in view rendering with a large
+ # number of complex assets.
+ config.assets.debug = true
+
+ # Suppress logger output for asset requests.
+ config.assets.quiet = false
+
+ # Raises error for missing translations
+ # config.action_view.raise_on_missing_translations = true
+
+ # Use an evented file watcher to asynchronously detect changes in source code,
+ # routes, locales, etc. This feature depends on the listen gem.
+ # config.file_watcher = ActiveSupport::EventedFileUpdateChecker
+end
diff --git a/config/environments/production.rb b/config/environments/production.rb
new file mode 100644
index 0000000..46dd8a8
--- /dev/null
+++ b/config/environments/production.rb
@@ -0,0 +1,82 @@
+Rails.application.configure do
+ # Settings specified here will take precedence over those in config/application.rb.
+
+ # Code is not reloaded between requests.
+ config.cache_classes = true
+
+ # Eager load code on boot. This eager loads most of Rails and
+ # your application in memory, allowing both threaded web servers
+ # and those relying on copy on write to perform better.
+ # Rake tasks automatically ignore this option for performance.
+ config.eager_load = true
+
+ # Full error reports are disabled and caching is turned on.
+ config.consider_all_requests_local = false
+ config.action_controller.perform_caching = true
+
+ # Disable serving static files from the `/public` folder by default since
+ # Apache or NGINX already handles this.
+ config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present?
+
+ # Compress JavaScripts and CSS.
+ config.assets.js_compressor = :uglifier
+ # config.assets.css_compressor = :sass
+
+ # Do not fallback to assets pipeline if a precompiled asset is missed.
+ config.assets.compile = false
+
+ # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb
+
+ # Enable serving of images, stylesheets, and JavaScripts from an asset server.
+ # config.action_controller.asset_host = 'http://assets.example.com'
+
+ # Specifies the header that your server uses for sending files.
+ # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache
+ # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX
+
+
+ # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
+ # config.force_ssl = true
+
+ # Use the lowest log level to ensure availability of diagnostic information
+ # when problems arise.
+ config.log_level = :info
+
+ # Prepend all log lines with the following tags.
+ config.log_tags = [ :request_id ]
+
+ # Use a different cache store in production.
+ # config.cache_store = :mem_cache_store
+
+ # Use a real queuing backend for Active Job (and separate queues per environment)
+ # config.active_job.queue_adapter = :resque
+ # config.active_job.queue_name_prefix = "deliver_#{Rails.env}"
+ config.action_mailer.perform_caching = false
+
+ # Ignore bad email addresses and do not raise email delivery errors.
+ # Set this to true and configure the email server for immediate delivery to raise delivery errors.
+ # config.action_mailer.raise_delivery_errors = false
+
+ # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
+ # the I18n.default_locale when a translation cannot be found).
+ config.i18n.fallbacks = true
+
+ # Send deprecation notices to registered listeners.
+ config.active_support.deprecation = :notify
+
+ # Use default logging formatter so that PID and timestamp are not suppressed.
+ config.log_formatter = ::Logger::Formatter.new
+
+ # Use a different logger for distributed setups.
+ # require 'syslog/logger'
+ # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name')
+
+ if ENV["RAILS_LOG_TO_STDOUT"].present?
+ logger = ActiveSupport::Logger.new(STDOUT)
+ logger.formatter = config.log_formatter
+ config.logger = ActiveSupport::TaggedLogging.new(logger)
+ end
+
+ # Do not dump schema after migrations.
+ config.active_record.dump_schema_after_migration = false
+end
diff --git a/config/environments/test.rb b/config/environments/test.rb
new file mode 100644
index 0000000..30587ef
--- /dev/null
+++ b/config/environments/test.rb
@@ -0,0 +1,42 @@
+Rails.application.configure do
+ # Settings specified here will take precedence over those in config/application.rb.
+
+ # The test environment is used exclusively to run your application's
+ # test suite. You never need to work with it otherwise. Remember that
+ # your test database is "scratch space" for the test suite and is wiped
+ # and recreated between test runs. Don't rely on the data there!
+ config.cache_classes = true
+
+ # Do not eager load code on boot. This avoids loading your whole application
+ # just for the purpose of running a single test. If you are using a tool that
+ # preloads Rails for running tests, you may have to set it to true.
+ config.eager_load = false
+
+ # Configure public file server for tests with Cache-Control for performance.
+ config.public_file_server.enabled = true
+ config.public_file_server.headers = {
+ 'Cache-Control' => 'public, max-age=3600'
+ }
+
+ # Show full error reports and disable caching.
+ config.consider_all_requests_local = true
+ config.action_controller.perform_caching = false
+
+ # Raise exceptions instead of rendering exception templates.
+ config.action_dispatch.show_exceptions = false
+
+ # Disable request forgery protection in test environment.
+ config.action_controller.allow_forgery_protection = false
+ config.action_mailer.perform_caching = false
+
+ # Tell Action Mailer not to deliver emails to the real world.
+ # The :test delivery method accumulates sent emails in the
+ # ActionMailer::Base.deliveries array.
+ config.action_mailer.delivery_method = :test
+
+ # Print deprecation notices to the stderr.
+ config.active_support.deprecation = :stderr
+
+ # Raises error for missing translations
+ # config.action_view.raise_on_missing_translations = true
+end
diff --git a/config/initializers/application_controller_renderer.rb b/config/initializers/application_controller_renderer.rb
new file mode 100644
index 0000000..51639b6
--- /dev/null
+++ b/config/initializers/application_controller_renderer.rb
@@ -0,0 +1,6 @@
+# Be sure to restart your server when you modify this file.
+
+# ApplicationController.renderer.defaults.merge!(
+# http_host: 'example.org',
+# https: false
+# )
diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb
new file mode 100644
index 0000000..01ef3e6
--- /dev/null
+++ b/config/initializers/assets.rb
@@ -0,0 +1,11 @@
+# Be sure to restart your server when you modify this file.
+
+# Version of your assets, change this if you want to expire all your assets.
+Rails.application.config.assets.version = '1.0'
+
+# Add additional assets to the asset load path
+# Rails.application.config.assets.paths << Emoji.images_path
+
+# Precompile additional assets.
+# application.js, application.css, and all non-JS/CSS in app/assets folder are already added.
+# Rails.application.config.assets.precompile += %w( search.js )
diff --git a/config/initializers/backtrace_silencers.rb b/config/initializers/backtrace_silencers.rb
new file mode 100644
index 0000000..59385cd
--- /dev/null
+++ b/config/initializers/backtrace_silencers.rb
@@ -0,0 +1,7 @@
+# Be sure to restart your server when you modify this file.
+
+# You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces.
+# Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ }
+
+# You can also remove all the silencers if you're trying to debug a problem that might stem from framework code.
+# Rails.backtrace_cleaner.remove_silencers!
diff --git a/config/initializers/cookies_serializer.rb b/config/initializers/cookies_serializer.rb
new file mode 100644
index 0000000..5a6a32d
--- /dev/null
+++ b/config/initializers/cookies_serializer.rb
@@ -0,0 +1,5 @@
+# Be sure to restart your server when you modify this file.
+
+# Specify a serializer for the signed and encrypted cookie jars.
+# Valid options are :json, :marshal, and :hybrid.
+Rails.application.config.action_dispatch.cookies_serializer = :json
diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb
new file mode 100644
index 0000000..4a994e1
--- /dev/null
+++ b/config/initializers/filter_parameter_logging.rb
@@ -0,0 +1,4 @@
+# Be sure to restart your server when you modify this file.
+
+# Configure sensitive parameters which will be filtered from the log file.
+Rails.application.config.filter_parameters += [:password]
diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb
new file mode 100644
index 0000000..8beea52
--- /dev/null
+++ b/config/initializers/inflections.rb
@@ -0,0 +1,23 @@
+# Be sure to restart your server when you modify this file.
+
+# Add new inflection rules using the following format. Inflections
+# are locale specific, and you may define rules for as many different
+# locales as you wish. All of these examples are active by default:
+# ActiveSupport::Inflector.inflections(:en) do |inflect|
+# inflect.plural /^(ox)$/i, '\1en'
+# inflect.singular /^(ox)en/i, '\1'
+# inflect.irregular 'person', 'people'
+# inflect.uncountable %w( fish sheep )
+# end
+
+# These inflection rules are supported but not enabled by default:
+ActiveSupport::Inflector.inflections(:en) do |inflect|
+ inflect.acronym 'API'
+ inflect.acronym 'SMTP'
+ inflect.acronym 'IP'
+ inflect.acronym 'DNS'
+ inflect.acronym 'UUID'
+ inflect.acronym 'HTTP'
+ inflect.acronym 'DB'
+ inflect.acronym 'DKIM'
+end
diff --git a/config/initializers/mail_extensions.rb b/config/initializers/mail_extensions.rb
new file mode 100644
index 0000000..034cf2e
--- /dev/null
+++ b/config/initializers/mail_extensions.rb
@@ -0,0 +1,111 @@
+require 'mail'
+module Mail
+ module Encodings
+ # Handle windows-1258 as windows-1252 when decoding
+ def Encodings.q_value_decode(str)
+ str = str.sub(/\=\?windows-?1258\?/i, '\=?windows-1252?')
+ RubyVer.q_value_decode(str)
+ end
+ def Encodings.b_value_decode(str)
+ str = str.sub(/\=\?windows-?1258\?/i, '\=?windows-1252?')
+ RubyVer.b_value_decode(str)
+ end
+ end
+
+ class Message
+ ## Extract plain text body of message
+ def plain_body
+ if self.multipart? and self.text_part
+ self.text_part.decoded
+ elsif self.mime_type == 'text/plain' || self.mime_type.nil?
+ self.decoded
+ else
+ nil
+ end
+ end
+
+ ## Extract HTML text body of message
+ def html_body
+ if self.multipart? and self.html_part
+ self.html_part.decoded
+ elsif self.mime_type == 'text/html'
+ self.decoded
+ else
+ nil
+ end
+ end
+
+ private
+
+ ## Fix bug in basic parsing
+ def parse_message
+ self.header, self.body = raw_source.split(/\r?\n\r?\n/m, 2)
+ end
+
+ # Handle attached emails as attachments
+ # Returns the filename of the attachment (if it exists) or returns nil
+ # Make up a filename for rfc822 attachments if it isn't specified
+ def find_attachment
+ content_type_name = header[:content_type].filename rescue nil
+ content_disp_name = header[:content_disposition].filename rescue nil
+ content_loc_name = header[:content_location].location rescue nil
+
+ if content_type && content_type_name
+ filename = content_type_name
+ elsif content_disposition && content_disp_name
+ filename = content_disp_name
+ elsif content_location && content_loc_name
+ filename = content_loc_name
+ elsif self.mime_type == "message/rfc822"
+ filename = "#{rand(100000000)}.eml"
+ else
+ filename = nil
+ end
+
+ if filename
+ # Normal decode
+ filename = Mail::Encodings.decode_encode(filename, :decode) rescue filename
+ end
+ filename
+ end
+
+ def decode_body_as_text
+ body_text = decode_body
+ charset_tmp = Encoding.find(Ruby19.pick_encoding(charset)) rescue 'ASCII'
+ charset_tmp = 'Windows-1252' if charset_tmp.to_s =~ /windows-?1258/i
+ if charset_tmp == Encoding.find('UTF-7')
+ body_text.force_encoding('UTF-8')
+ decoded = body_text.gsub(/\+.*?\-/m) {|n|Base64.decode64(n[1..-2]+'===').force_encoding('UTF-16BE').encode('UTF-8')}
+ else
+ body_text.force_encoding(charset_tmp)
+ decoded = body_text.encode("utf-8", :invalid => :replace, :undef => :replace)
+ end
+ decoded.valid_encoding? ? decoded : decoded.encode("utf-16le", :invalid => :replace, :undef => :replace).encode("utf-8")
+ end
+ end
+
+ # Handle attached emails as attachments
+ class AttachmentsList < Array
+ def initialize(parts_list)
+ @parts_list = parts_list
+ @content_disposition_type = 'attachment'
+ parts_list.map { |p|
+ (p.parts.empty? and p.attachment?) ? p : p.attachments
+ }.flatten.compact.each { |a| self << a }
+ self
+ end
+ end
+end
+
+class Array
+ def decoded
+ return nil if self.empty?
+ return self.first.decoded
+ end
+end
+
+class NilClass
+ def decoded
+ nil
+ end
+end
diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb
new file mode 100644
index 0000000..dc18996
--- /dev/null
+++ b/config/initializers/mime_types.rb
@@ -0,0 +1,4 @@
+# Be sure to restart your server when you modify this file.
+
+# Add new mime types for use in respond_to blocks:
+# Mime::Type.register "text/richtext", :rtf
diff --git a/config/initializers/new_framework_defaults.rb b/config/initializers/new_framework_defaults.rb
new file mode 100644
index 0000000..9a32c10
--- /dev/null
+++ b/config/initializers/new_framework_defaults.rb
@@ -0,0 +1,24 @@
+# Be sure to restart your server when you modify this file.
+#
+# This file contains migration options to ease your Rails 5.0 upgrade.
+#
+# Read the Rails 5.0 release notes for more info on each option.
+
+# Enable per-form CSRF tokens. Previous versions had false.
+Rails.application.config.action_controller.per_form_csrf_tokens = true
+
+# Enable origin-checking CSRF mitigation. Previous versions had false.
+Rails.application.config.action_controller.forgery_protection_origin_check = true
+
+# Make Ruby 2.4 preserve the timezone of the receiver when calling `to_time`.
+# Previous versions had false.
+ActiveSupport.to_time_preserves_timezone = true
+
+# Require `belongs_to` associations by default. Previous versions had false.
+Rails.application.config.active_record.belongs_to_required_by_default = true
+
+# Do not halt callback chains when a callback returns false. Previous versions had true.
+ActiveSupport.halt_callback_chains_on_return_false = false
+
+# Configure SSL options to enable HSTS with subdomains. Previous versions had false.
+Rails.application.config.ssl_options = false
diff --git a/config/initializers/postal.rb b/config/initializers/postal.rb
new file mode 100644
index 0000000..ed04aed
--- /dev/null
+++ b/config/initializers/postal.rb
@@ -0,0 +1,2 @@
+require 'postal/error'
+require 'postal/message_db/mysql'
diff --git a/config/initializers/record_key_for_dom.rb b/config/initializers/record_key_for_dom.rb
new file mode 100644
index 0000000..b2bd913
--- /dev/null
+++ b/config/initializers/record_key_for_dom.rb
@@ -0,0 +1,12 @@
+module ActionView
+ module RecordIdentifier
+ def dom_id(record, prefix = nil)
+ if record.new_record?
+ dom_class(record, prefix || NEW)
+ else
+ id = record.respond_to?(:uuid) ? record.uuid : record.id
+ "#{dom_class(record, prefix)}#{JOIN}#{id}"
+ end
+ end
+ end
+end
diff --git a/config/initializers/secret_key.rb b/config/initializers/secret_key.rb
new file mode 100644
index 0000000..0c90da4
--- /dev/null
+++ b/config/initializers/secret_key.rb
@@ -0,0 +1,6 @@
+if Postal.config.rails&.secret_key
+ Rails.application.secrets.secret_key_base = Postal.config.rails.secret_key
+else
+ $stderr.puts "No secret key was specified in the Postal config file. Using one for just this session"
+ Rails.application.secrets.secret_key_base = SecureRandom.hex(128)
+end
diff --git a/config/initializers/secure_headers.rb b/config/initializers/secure_headers.rb
new file mode 100644
index 0000000..17747dc
--- /dev/null
+++ b/config/initializers/secure_headers.rb
@@ -0,0 +1,10 @@
+SecureHeaders::Configuration.default do |config|
+
+ config.hsts = SecureHeaders::OPT_OUT
+
+ config.csp[:default_src] = []
+ config.csp[:script_src] = ["'self'"]
+ config.csp[:child_src] = ["'self'"]
+ config.csp[:connect_src] = ["'self'"]
+
+end
diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb
new file mode 100644
index 0000000..b28e197
--- /dev/null
+++ b/config/initializers/sentry.rb
@@ -0,0 +1,14 @@
+require 'postal/config'
+
+if Postal.config.general&.exception_url
+ require 'raven'
+ Raven.configure do |config|
+ config.dsn = Postal.config.general.exception_url
+ config.environments = ['production']
+ if ENV['DEV_EXCEPTIONS']
+ config.environments << 'development'
+ end
+ config.silence_ready = true
+ config.tags = {:process => ENV['PROC_NAME']}
+ end
+end
diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb
new file mode 100644
index 0000000..92d9a06
--- /dev/null
+++ b/config/initializers/session_store.rb
@@ -0,0 +1,3 @@
+# Be sure to restart your server when you modify this file.
+
+Rails.application.config.session_store :cookie_store, key: '_deliver_session'
diff --git a/config/initializers/smtp.rb b/config/initializers/smtp.rb
new file mode 100644
index 0000000..0b2c348
--- /dev/null
+++ b/config/initializers/smtp.rb
@@ -0,0 +1,5 @@
+require 'postal/config'
+if Postal.config&.smtp
+ ActionMailer::Base.delivery_method = :smtp
+ ActionMailer::Base.smtp_settings = {:address => Postal.config.smtp.host, :user_name => Postal.config.smtp.username, :password => Postal.config.smtp.password}
+end
diff --git a/config/initializers/smtp_extensions.rb b/config/initializers/smtp_extensions.rb
new file mode 100644
index 0000000..b9f499a
--- /dev/null
+++ b/config/initializers/smtp_extensions.rb
@@ -0,0 +1,34 @@
+class Net::SMTP::Response
+ def message
+ @string
+ end
+end
+
+class Net::SMTP
+ attr_accessor :source_address
+
+ def secure_socket?
+ @socket.is_a?(OpenSSL::SSL::SSLSocket)
+ end
+
+ #
+ # We had an issue where a message was sent to a server and was greylisted. It returned
+ # a Net::SMTPUnknownError error. We then tried to send another message on the same
+ # connection after running `rset` the next message didn't raise any exceptions because
+ # net/smtp returns a '200 dummy reply code' and doesn't raise any exceptions.
+ #
+ def rset
+ @error_occurred = false
+ getok('RSET')
+ end
+
+ def rset_errors
+ @error_occurred = false
+ end
+
+ private
+
+ def tcp_socket(address, port)
+ TCPSocket.open(address, port, self.source_address)
+ end
+end
diff --git a/config/initializers/trusted_proxies.rb b/config/initializers/trusted_proxies.rb
new file mode 100644
index 0000000..c94ba2a
--- /dev/null
+++ b/config/initializers/trusted_proxies.rb
@@ -0,0 +1,9 @@
+module Rack
+ class Request
+ module Helpers
+ def trusted_proxy?(ip)
+ ip =~ /^127\.0\.0\.1$|^localhost$|^unix$$/i
+ end
+ end
+ end
+end
diff --git a/config/initializers/wrap_parameters.rb b/config/initializers/wrap_parameters.rb
new file mode 100644
index 0000000..bbfc396
--- /dev/null
+++ b/config/initializers/wrap_parameters.rb
@@ -0,0 +1,14 @@
+# Be sure to restart your server when you modify this file.
+
+# This file contains settings for ActionController::ParamsWrapper which
+# is enabled by default.
+
+# Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array.
+ActiveSupport.on_load(:action_controller) do
+ wrap_parameters format: [:json]
+end
+
+# To enable root element in JSON for ActiveRecord objects.
+# ActiveSupport.on_load(:active_record) do
+# self.include_root_in_json = true
+# end
diff --git a/config/locales/en.yml b/config/locales/en.yml
new file mode 100644
index 0000000..d9b128a
--- /dev/null
+++ b/config/locales/en.yml
@@ -0,0 +1,80 @@
+# Files in the config/locales directory are used for internationalization
+# and are automatically loaded by Rails. If you want to use locales other
+# than English, add the necessary files in this directory.
+#
+# To use the locales, use `I18n.t`:
+#
+# I18n.t 'hello'
+#
+# In views, this is aliased to just `t`:
+#
+# <%= t('hello') %>
+#
+# To use a different locale, set it with `I18n.locale`:
+#
+# I18n.locale = :es
+#
+# This would use the information in config/locales/es.yml.
+#
+# To learn more, please read the Rails Internationalization guide
+# available at http://guides.rubyonrails.org/i18n.html.
+
+en:
+ hello: "Hello world"
+ activerecord:
+ attributes:
+ organization:
+ permalink: Short name
+ server:
+ permalink: Short name
+ domain:
+ verification_method: Verify Method
+ http_endpoint:
+ url: URL
+ user:
+ email_address: E-Mail address
+ webhook:
+ url: URL
+ sign: Sign requests
+
+ server_statuses:
+ live: Live
+ development: Dev
+ suspended: Suspended
+ route_spam_modes:
+ quarantine: Spam will be quarantined
+ mark: Spam will be marked
+
+ http_endpoint_formats:
+ hash: Delivered as a hash
+ raw_message: Delivered as the raw message
+ http_endpoint_encodings:
+ body_as_json: Sent in the body as JSON
+ form_data: Sent as form data
+
+ webhook_events:
+ message_sent: An e-mail has been successfully delivered to its endpoint (either SMTP or HTTP).
+ message_delayed: An e-mail has been delayed due to an issue with the receiving endpoint. It will be retried automatically.
+ message_delivery_failed: An e-mail cannot be delivered to its endpoint. This is a permanent failure so it will no be retried.
+ message_held: An e-mail has been held in Postal. This will be because a limit has been reached or your server is in development mode.
+ message_bounced: We received a bounce message in response to an email which had previously been successfully sent.
+ message_link_clicked: A link in one of your outbound messages has been clicked.
+ message_loaded: A message you have sent has been loaded.
+ domain_dns_error: This will be triggered when we detect an issue with the DNS configuration for any domain for this server.
+ send_limit_approaching: This will be triggered when your mail server is approaching its send limit. It will only be sent once per hour.
+ send_limit_exceeded: This will be triggered when your mail server exceeded its send limit.
+
+ currencies:
+ gbp: GBP - Great British Pound (£)
+ usd: USD - United States Dollar ($)
+ eur: EUR - Euro (€)
+
+ route_modes:
+ accept: Accept message with no endpoint
+ hold: Accept message and put message in hold queue
+ bounce: Accept message and immediately send bounce to sender
+ reject: Do not accept any incoming messages
+
+ renewal_issues:
+ no_payment_card: You don't have a payment card on file
+ payment_declined: The payment for this service was declined
diff --git a/config/postal.development.yml b/config/postal.development.yml
new file mode 100644
index 0000000..aa30c16
--- /dev/null
+++ b/config/postal.development.yml
@@ -0,0 +1,77 @@
+web:
+ host: localhost:5000
+ protocol: http
+ fast_server_host: localhost:5010
+ fast_server_protocol: http
+
+web_server:
+ bind_address: 0.0.0.0
+ port: 5000
+ max_threads: 5
+
+fast_server:
+ bind_address: 0.0.0.0
+ port: 5010
+ ssl_port: 5011
+ proxy_protocol: false
+
+general:
+ use_ip_pools: false
+ exception_url: xxx
+
+logging:
+ stdout: false
+ max_log_file_size: 20
+ max_log_files: 10
+
+main_db:
+ host: localhost
+ port: 3306
+ username: root
+ password:
+ database: postal
+
+message_db:
+ host: localhost
+ port: 3306
+ username: root
+ password:
+ prefix: postal
+
+rabbitmq:
+ host: localhost
+ port: 5672
+ username: guest
+ password: guest
+ vhost: null
+
+smtp_server:
+ proxy_protocol: false
+ log_connect: true
+ evented: false
+ ports:
+ - 2525
+
+dns:
+ spf_include: spf.example.com
+ dkim_identifier: postal
+ domain_verify_prefix: postal-verification
+ custom_return_path_prefix: psrp
+ return_path: amrp.example.com
+ smtp_server_hostname: smtp.example.com
+ route_domain: routes.example.com
+ track_domain: track.example.com
+ mx_records:
+ - mx1.example.com
+ - mx2.example.com
+
+smtp:
+ host: smtp.atech.io
+
+espect:
+ hosts:
+ - http://espect01.infra.atech.io
+
+rails:
+ environment: development
+ secret_key: 1bd1a4fe0cb3fb166be9b0e388beb36557b49ae5bed97ab3e300353de06d1fe55522b21596db8ed5d83c031ca58696340fb3d70f59eb0c096bbe4034fc12e4cd
diff --git a/config/postal.example.yml b/config/postal.example.yml
new file mode 100644
index 0000000..37e43b9
--- /dev/null
+++ b/config/postal.example.yml
@@ -0,0 +1,79 @@
+web:
+ host: postal.example.com
+ protocol: https
+ fast_server_host: postal-click.example.com
+ fast_server_protocol: https
+
+web_server:
+ bind_address: 0.0.0.0
+ port: 5000
+ max_threads: 5
+
+fast_server:
+ bind_address: 0.0.0.0
+ port: 5010
+ ssl_port: 5011
+ proxy_protocol: true
+
+general:
+ use_ip_pools: true
+ exception_url: xxx
+
+logging:
+ stdout: false
+ max_log_file_size: 20
+ max_log_files: 10
+
+main_db:
+ host: localhost
+ port: 3306
+ username: root
+ password:
+ database: postal
+
+message_db:
+ host: localhost
+ port: 3306
+ username: root
+ password:
+ prefix: postal
+
+rabbitmq:
+ host: localhost
+ port: 5672
+ username: postal
+ password: xxx
+ vhost: postal
+
+smtp_server:
+ proxy_protocol: false
+ log_connect: true
+ log_exclude_ips: null
+ evented: true
+ ports:
+ - 2525
+
+dns:
+ spf_include: spf.example.com
+ dkim_identifier: postal
+ domain_verify_prefix: postal-verification
+ custom_return_path_prefix: psrp
+ return_path: amrp.example.com
+ smtp_server_hostname: smtp.example.com
+ route_domain: routes.example.com
+ track_domain: track.example.com
+ mx_records:
+ - mx1.example.com
+ - mx2.example.com
+
+smtp:
+ host: smtp.blah
+
+espect:
+ hosts:
+ - http://espect01.infra.atech.io
+
+rails:
+ environment: development
+ secret_key: 1bd1a4fe0cb3fb166be9b0e388beb36557b49ae5bed97ab3e300353de06d1fe55522b21596db8ed5d83c031ca58696340fb3d70f59eb0c096bbe4034fc12e4cd
+
diff --git a/config/puma.fast.rb b/config/puma.fast.rb
new file mode 100644
index 0000000..4eb3fdf
--- /dev/null
+++ b/config/puma.fast.rb
@@ -0,0 +1,17 @@
+require_relative '../lib/postal/config'
+threads_count = Postal.config.fast_server&.max_threads&.to_i || 5
+threads threads_count, threads_count
+bind_address = Postal.config.fast_server&.bind_address || '127.0.0.1'
+bind_port = Postal.config.fast_server&.port&.to_i || 5010
+bind "tcp://#{bind_address}:#{bind_port}"
+environment Postal.config.rails&.environment || 'development'
+prune_bundler
+quiet false
+rackup File.expand_path('../../lib/postal/fast_server/config.ru', __FILE__)
+unless ENV['LOG_TO_STDOUT']
+ stdout_redirect Postal.app_root.join('log', 'puma.fast.log'), Postal.app_root.join('log', 'puma.fast.log'), true
+end
+
+if ENV['APP_ROOT']
+ directory ENV['APP_ROOT']
+end
diff --git a/config/puma.rb b/config/puma.rb
new file mode 100644
index 0000000..5fcd3a2
--- /dev/null
+++ b/config/puma.rb
@@ -0,0 +1,16 @@
+require_relative '../lib/postal/config'
+threads_count = Postal.config.web_server&.max_threads&.to_i || 5
+threads threads_count, threads_count
+bind_address = Postal.config.web_server&.bind_address || '127.0.0.1'
+bind_port = Postal.config.web_server&.port&.to_i || 5000
+bind "tcp://#{bind_address}:#{bind_port}"
+environment Postal.config.rails&.environment || 'development'
+prune_bundler
+quiet false
+unless ENV['LOG_TO_STDOUT']
+ stdout_redirect Postal.app_root.join('log', 'puma.log'), Postal.app_root.join('log', 'puma.log'), true
+end
+
+if ENV['APP_ROOT']
+ directory ENV['APP_ROOT']
+end
diff --git a/config/routes.rb b/config/routes.rb
new file mode 100644
index 0000000..c30e693
--- /dev/null
+++ b/config/routes.rb
@@ -0,0 +1,95 @@
+Rails.application.routes.draw do
+
+ scope "org/:org_permalink", :as => 'organization' do
+ resources :domains, :only => [:index, :new, :create, :destroy] do
+ match :verify, :on => :member, :via => [:get, :post]
+ get :setup, :on => :member
+ post :check, :on => :member
+ end
+ resources :servers, :except => [:index] do
+ resources :domains, :only => [:index, :new, :create, :destroy] do
+ match :verify, :on => :member, :via => [:get, :post]
+ get :setup, :on => :member
+ post :check, :on => :member
+ end
+ resources :track_domains do
+ post :toggle_ssl, :on => :member
+ post :check, :on => :member
+ end
+ resources :credentials
+ resources :routes
+ resources :http_endpoints
+ resources :smtp_endpoints
+ resources :address_endpoints
+ resources :ip_pool_rules
+ resources :messages do
+ get :incoming, :on => :collection
+ get :outgoing, :on => :collection
+ get :held, :on => :collection
+ get :activity, :on => :member
+ get :plain, :on => :member
+ get :html, :on => :member
+ get :html_raw, :on => :member
+ get :attachments, :on => :member
+ get :headers, :on => :member
+ get :attachment, :on => :member
+ get :download, :on => :member
+ get :spam_checks, :on => :member
+ post :retry, :on => :member
+ post :cancel_hold, :on => :member
+ get :suppressions, :on => :collection
+ delete :remove_from_queue, :on => :member
+ get :deliveries, :on => :member
+ end
+ resources :webhooks do
+ get :history, :on => :collection
+ get 'history/:uuid', :on => :collection, :action => 'history_request', :as => 'history_request'
+ end
+ get :limits, :on => :member
+ get :retention, :on => :member
+ get :queue, :on => :member
+ get :spam, :on => :member
+ get :delete, :on => :member
+ get 'help/outgoing' => 'help#outgoing'
+ get 'help/incoming' => 'help#incoming'
+ get :admin, :on => :member
+ post :suspend, :on => :member
+ post :unsuspend, :on => :member
+ end
+ resources :users do
+ post :make_owner, :on => :member
+ end
+ resources :ip_pool_rules
+ resources :ip_pools
+ root 'servers#index'
+ get 'settings' => 'organizations#edit'
+ patch 'settings' => 'organizations#update'
+ get 'delete' => 'organizations#delete'
+ delete 'delete' => 'organizations#destroy'
+ end
+
+ resources :organizations, :except => [:index]
+
+ namespace :admin do
+ resources :organizations
+ get 'stats' => 'stats#stats'
+ root :to => redirect("/admin/organizations")
+ end
+
+ get 'settings' => 'user#edit'
+ patch 'settings' => 'user#update'
+ post 'persist' => 'sessions#persist'
+
+ match 'verify' => 'user#verify', :via => [:get, :post]
+ get 'signup' => 'user#new'
+ post 'signup' => 'user#create'
+ match 'join/:token' => 'user#join', :via => [:get, :post, :delete]
+ get 'login' => 'sessions#new'
+ post 'login' => 'sessions#create'
+ get 'login/token' => 'sessions#create_with_token'
+ delete 'logout' => 'sessions#destroy'
+ match 'login/reset' => 'sessions#begin_password_reset', :via => [:get, :post]
+ match 'login/reset/:token' => 'sessions#finish_password_reset', :via => [:get, :post]
+ root 'organizations#index'
+ get 'ip' => 'sessions#ip'
+end
diff --git a/db/migrate/20161003195209_create_authie_sessions.authie.rb b/db/migrate/20161003195209_create_authie_sessions.authie.rb
new file mode 100644
index 0000000..fadf717
--- /dev/null
+++ b/db/migrate/20161003195209_create_authie_sessions.authie.rb
@@ -0,0 +1,5 @@
+# This migration comes from authie (originally 20141012174250)
+class CreateAuthieSessions < ActiveRecord::Migration
+ def change
+ end
+end
diff --git a/db/migrate/20161003195210_add_indexes_to_authie_sessions.authie.rb b/db/migrate/20161003195210_add_indexes_to_authie_sessions.authie.rb
new file mode 100644
index 0000000..a3f7127
--- /dev/null
+++ b/db/migrate/20161003195210_add_indexes_to_authie_sessions.authie.rb
@@ -0,0 +1,5 @@
+# This migration comes from authie (originally 20141013115205)
+class AddIndexesToAuthieSessions < ActiveRecord::Migration
+ def change
+ end
+end
diff --git a/db/migrate/20161003195211_add_parent_id_to_authie_sessions.authie.rb b/db/migrate/20161003195211_add_parent_id_to_authie_sessions.authie.rb
new file mode 100644
index 0000000..224dd28
--- /dev/null
+++ b/db/migrate/20161003195211_add_parent_id_to_authie_sessions.authie.rb
@@ -0,0 +1,5 @@
+# This migration comes from authie (originally 20150109144120)
+class AddParentIdToAuthieSessions < ActiveRecord::Migration
+ def change
+ end
+end
diff --git a/db/migrate/20161003195212_add_two_factor_auth_fields_to_authie.authie.rb b/db/migrate/20161003195212_add_two_factor_auth_fields_to_authie.authie.rb
new file mode 100644
index 0000000..2a016b4
--- /dev/null
+++ b/db/migrate/20161003195212_add_two_factor_auth_fields_to_authie.authie.rb
@@ -0,0 +1,5 @@
+# This migration comes from authie (originally 20150305135400)
+class AddTwoFactorAuthFieldsToAuthie < ActiveRecord::Migration
+ def change
+ end
+end
diff --git a/db/migrate/20170418200606_initial_schema.rb b/db/migrate/20170418200606_initial_schema.rb
new file mode 100644
index 0000000..e8ffbc1
--- /dev/null
+++ b/db/migrate/20170418200606_initial_schema.rb
@@ -0,0 +1,347 @@
+class InitialSchema < ActiveRecord::Migration
+ def up
+
+ create_table "additional_route_endpoints", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "route_id"
+ t.string "endpoint_type"
+ t.integer "endpoint_id"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ create_table "address_endpoints", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "server_id"
+ t.string "uuid"
+ t.string "address"
+ t.datetime "last_used_at"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ create_table "authie_sessions", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.string "token"
+ t.string "browser_id"
+ t.integer "user_id"
+ t.boolean "active", default: true
+ t.text "data", limit: 65535
+ t.datetime "expires_at"
+ t.datetime "login_at"
+ t.string "login_ip"
+ t.datetime "last_activity_at"
+ t.string "last_activity_ip"
+ t.string "last_activity_path"
+ t.string "user_agent"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ t.string "user_type"
+ t.integer "parent_id"
+ t.datetime "two_factored_at"
+ t.string "two_factored_ip"
+ t.integer "requests", default: 0
+ t.datetime "password_seen_at"
+ t.string "token_hash"
+ t.index ["browser_id"], name: "index_authie_sessions_on_browser_id", length: { browser_id: 8 }, using: :btree
+ t.index ["token"], name: "index_authie_sessions_on_token", length: { token: 8 }, using: :btree
+ t.index ["user_id"], name: "index_authie_sessions_on_user_id", using: :btree
+ end
+
+ create_table "credentials", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "server_id"
+ t.string "key"
+ t.string "type"
+ t.string "name"
+ t.text "options", limit: 65535
+ t.datetime "last_used_at", precision: 6
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ t.boolean "hold", default: false
+ end
+
+ create_table "domains", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "server_id"
+ t.string "uuid"
+ t.string "name"
+ t.string "verification_token"
+ t.string "verification_method"
+ t.datetime "verified_at"
+ t.text "dkim_private_key", limit: 65535
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ t.datetime "dns_checked_at", precision: 6
+ t.string "spf_status"
+ t.string "spf_error"
+ t.string "dkim_status"
+ t.string "dkim_error"
+ t.string "mx_status"
+ t.string "mx_error"
+ t.string "return_path_status"
+ t.string "return_path_error"
+ t.boolean "outgoing", default: true
+ t.boolean "incoming", default: true
+ t.string "owner_type"
+ t.integer "owner_id"
+ t.string "dkim_identifier_string"
+ t.boolean "use_for_any"
+ t.index ["server_id"], name: "index_domains_on_server_id", using: :btree
+ t.index ["uuid"], name: "index_domains_on_uuid", length: { uuid: 8 }, using: :btree
+ end
+
+ create_table "http_endpoints", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "server_id"
+ t.string "uuid"
+ t.string "name"
+ t.string "url"
+ t.string "encoding"
+ t.string "format"
+ t.boolean "strip_replies", default: false
+ t.text "error", limit: 65535
+ t.datetime "disabled_until", precision: 6
+ t.datetime "last_used_at", precision: 6
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ t.boolean "include_attachments", default: true
+ t.integer "timeout"
+ end
+
+ create_table "ip_addresses", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "ip_pool_id"
+ t.string "ipv4"
+ t.string "ipv6"
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ t.string "hostname"
+ end
+
+ create_table "ip_pool_rules", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.string "uuid"
+ t.string "owner_type"
+ t.integer "owner_id"
+ t.integer "ip_pool_id"
+ t.text "from_text", limit: 65535
+ t.text "to_text", limit: 65535
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ create_table "ip_pools", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.string "name"
+ t.string "uuid"
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ t.boolean "default", default: false
+ t.string "type"
+ t.index ["uuid"], name: "index_ip_pools_on_uuid", length: { uuid: 8 }, using: :btree
+ end
+
+ create_table "organization_ip_pools", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "organization_id"
+ t.integer "ip_pool_id"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ create_table "organization_users", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "organization_id"
+ t.integer "user_id"
+ t.datetime "created_at", precision: 6
+ t.boolean "admin", default: false
+ t.boolean "all_servers", default: true
+ t.string "user_type"
+ end
+
+ create_table "organizations", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.string "uuid"
+ t.string "name"
+ t.string "permalink"
+ t.string "time_zone"
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ t.integer "ip_pool_id"
+ t.integer "owner_id"
+ t.datetime "deleted_at", precision: 6
+ t.datetime "suspended_at", precision: 6
+ t.string "suspension_reason"
+ t.index ["permalink"], name: "index_organizations_on_permalink", length: { permalink: 8 }, using: :btree
+ t.index ["uuid"], name: "index_organizations_on_uuid", length: { uuid: 8 }, using: :btree
+ end
+
+ create_table "queued_messages", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "server_id"
+ t.integer "message_id"
+ t.string "domain"
+ t.string "locked_by"
+ t.datetime "locked_at", precision: 6
+ t.datetime "retry_after"
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ t.integer "ip_address_id"
+ t.integer "attempts", default: 0
+ t.integer "route_id"
+ t.boolean "manual", default: false
+ t.string "batch_key"
+ t.index ["domain"], name: "index_queued_messages_on_domain", length: { domain: 8 }, using: :btree
+ t.index ["message_id"], name: "index_queued_messages_on_message_id", using: :btree
+ t.index ["server_id"], name: "index_queued_messages_on_server_id", using: :btree
+ end
+
+ create_table "routes", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.string "uuid"
+ t.integer "server_id"
+ t.integer "domain_id"
+ t.integer "endpoint_id"
+ t.string "endpoint_type"
+ t.string "name"
+ t.string "spam_mode"
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ t.string "token"
+ t.string "mode"
+ t.index ["token"], name: "index_routes_on_token", length: { token: 6 }, using: :btree
+ end
+
+ create_table "servers", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "organization_id"
+ t.string "uuid"
+ t.string "name"
+ t.string "mode"
+ t.integer "ip_pool_id"
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ t.string "permalink"
+ t.integer "send_limit"
+ t.datetime "deleted_at", precision: 6
+ t.integer "message_retention_days"
+ t.integer "raw_message_retention_days"
+ t.integer "raw_message_retention_size"
+ t.boolean "allow_sender", default: false
+ t.string "token"
+ t.datetime "send_limit_approaching_at", precision: 6
+ t.datetime "send_limit_approaching_notified_at", precision: 6
+ t.datetime "send_limit_exceeded_at", precision: 6
+ t.datetime "send_limit_exceeded_notified_at", precision: 6
+ t.decimal "spam_threshold", precision: 8, scale: 2
+ t.decimal "spam_failure_threshold", precision: 8, scale: 2
+ t.string "postmaster_address"
+ t.datetime "suspended_at", precision: 6
+ t.decimal "outbound_spam_threshold", precision: 8, scale: 2
+ t.text "domains_not_to_click_track", limit: 65535
+ t.string "suspension_reason"
+ t.boolean "log_smtp_data", default: false
+ t.index ["organization_id"], name: "index_servers_on_organization_id", using: :btree
+ t.index ["permalink"], name: "index_servers_on_permalink", length: { permalink: 6 }, using: :btree
+ t.index ["token"], name: "index_servers_on_token", length: { token: 6 }, using: :btree
+ t.index ["uuid"], name: "index_servers_on_uuid", length: { uuid: 8 }, using: :btree
+ end
+
+ create_table "smtp_endpoints", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "server_id"
+ t.string "uuid"
+ t.string "name"
+ t.string "hostname"
+ t.string "ssl_mode"
+ t.integer "port"
+ t.text "error", limit: 65535
+ t.datetime "disabled_until", precision: 6
+ t.datetime "last_used_at", precision: 6
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ end
+
+ create_table "statistics", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.bigint "total_messages", default: 0
+ t.bigint "total_outgoing", default: 0
+ t.bigint "total_incoming", default: 0
+ end
+
+ create_table "track_certificates", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.string "domain"
+ t.text "certificate", limit: 65535
+ t.text "intermediaries", limit: 65535
+ t.text "key", limit: 65535
+ t.datetime "expires_at"
+ t.datetime "renew_after"
+ t.string "verification_path"
+ t.string "verification_string"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["domain"], name: "index_track_certificates_on_domain", length: { domain: 8 }, using: :btree
+ end
+
+ create_table "track_domains", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.string "uuid"
+ t.integer "server_id"
+ t.integer "domain_id"
+ t.string "name"
+ t.datetime "dns_checked_at"
+ t.string "dns_status"
+ t.string "dns_error"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.boolean "ssl_enabled", default: true
+ t.boolean "track_clicks", default: true
+ t.boolean "track_loads", default: true
+ t.text "excluded_click_domains", limit: 65535
+ end
+
+ create_table "user_invites", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.string "uuid"
+ t.string "email_address"
+ t.datetime "expires_at", precision: 6
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ t.index ["uuid"], name: "index_user_invites_on_uuid", length: { uuid: 12 }, using: :btree
+ end
+
+ create_table "users", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.string "uuid"
+ t.string "first_name"
+ t.string "last_name"
+ t.string "email_address"
+ t.string "password_digest"
+ t.string "time_zone"
+ t.string "email_verification_token"
+ t.datetime "email_verified_at"
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ t.string "password_reset_token"
+ t.datetime "password_reset_token_valid_until"
+ t.boolean "admin", default: false
+ t.index ["email_address"], name: "index_users_on_email_address", length: { email_address: 8 }, using: :btree
+ t.index ["uuid"], name: "index_users_on_uuid", length: { uuid: 8 }, using: :btree
+ end
+
+ create_table "webhook_events", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "webhook_id"
+ t.string "event"
+ t.datetime "created_at", precision: 6
+ t.index ["webhook_id"], name: "index_webhook_events_on_webhook_id", using: :btree
+ end
+
+ create_table "webhook_requests", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "server_id"
+ t.integer "webhook_id"
+ t.string "url"
+ t.string "event"
+ t.string "uuid"
+ t.text "payload", limit: 65535
+ t.integer "attempts", default: 0
+ t.datetime "retry_after", precision: 6
+ t.text "error", limit: 65535
+ t.datetime "created_at", precision: 6
+ end
+
+ create_table "webhooks", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "server_id"
+ t.string "uuid"
+ t.string "name"
+ t.string "url"
+ t.datetime "last_used_at"
+ t.boolean "all_events", default: false
+ t.boolean "enabled", default: true
+ t.boolean "sign", default: true
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ t.index ["server_id"], name: "index_webhooks_on_server_id", using: :btree
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
new file mode 100644
index 0000000..7607df7
--- /dev/null
+++ b/db/schema.rb
@@ -0,0 +1,358 @@
+# This file is auto-generated from the current state of the database. Instead
+# of editing this file, please use the migrations feature of Active Record to
+# incrementally modify your database, and then regenerate this schema definition.
+#
+# Note that this schema.rb definition is the authoritative source for your
+# database schema. If you need to create the application database on another
+# system, you should be using db:schema:load, not running all the migrations
+# from scratch. The latter is a flawed and unsustainable approach (the more migrations
+# you'll amass, the slower it'll run and the greater likelihood for issues).
+#
+# It's strongly recommended that you check this file into your version control system.
+
+ActiveRecord::Schema.define(version: 20170418200606) do
+
+ create_table "additional_route_endpoints", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "route_id"
+ t.string "endpoint_type"
+ t.integer "endpoint_id"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ create_table "address_endpoints", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "server_id"
+ t.string "uuid"
+ t.string "address"
+ t.datetime "last_used_at"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ create_table "authie_sessions", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.string "token"
+ t.string "browser_id"
+ t.integer "user_id"
+ t.boolean "active", default: true
+ t.text "data", limit: 65535
+ t.datetime "expires_at"
+ t.datetime "login_at"
+ t.string "login_ip"
+ t.datetime "last_activity_at"
+ t.string "last_activity_ip"
+ t.string "last_activity_path"
+ t.string "user_agent"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ t.string "user_type"
+ t.integer "parent_id"
+ t.datetime "two_factored_at"
+ t.string "two_factored_ip"
+ t.integer "requests", default: 0
+ t.datetime "password_seen_at"
+ t.string "token_hash"
+ t.index ["browser_id"], name: "index_authie_sessions_on_browser_id", length: { browser_id: 8 }, using: :btree
+ t.index ["token"], name: "index_authie_sessions_on_token", length: { token: 8 }, using: :btree
+ t.index ["user_id"], name: "index_authie_sessions_on_user_id", using: :btree
+ end
+
+ create_table "credentials", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "server_id"
+ t.string "key"
+ t.string "type"
+ t.string "name"
+ t.text "options", limit: 65535
+ t.datetime "last_used_at", precision: 6
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ t.boolean "hold", default: false
+ end
+
+ create_table "domains", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "server_id"
+ t.string "uuid"
+ t.string "name"
+ t.string "verification_token"
+ t.string "verification_method"
+ t.datetime "verified_at"
+ t.text "dkim_private_key", limit: 65535
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ t.datetime "dns_checked_at", precision: 6
+ t.string "spf_status"
+ t.string "spf_error"
+ t.string "dkim_status"
+ t.string "dkim_error"
+ t.string "mx_status"
+ t.string "mx_error"
+ t.string "return_path_status"
+ t.string "return_path_error"
+ t.boolean "outgoing", default: true
+ t.boolean "incoming", default: true
+ t.string "owner_type"
+ t.integer "owner_id"
+ t.string "dkim_identifier_string"
+ t.boolean "use_for_any"
+ t.index ["server_id"], name: "index_domains_on_server_id", using: :btree
+ t.index ["uuid"], name: "index_domains_on_uuid", length: { uuid: 8 }, using: :btree
+ end
+
+ create_table "http_endpoints", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "server_id"
+ t.string "uuid"
+ t.string "name"
+ t.string "url"
+ t.string "encoding"
+ t.string "format"
+ t.boolean "strip_replies", default: false
+ t.text "error", limit: 65535
+ t.datetime "disabled_until", precision: 6
+ t.datetime "last_used_at", precision: 6
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ t.boolean "include_attachments", default: true
+ t.integer "timeout"
+ end
+
+ create_table "ip_addresses", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "ip_pool_id"
+ t.string "ipv4"
+ t.string "ipv6"
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ t.string "hostname"
+ end
+
+ create_table "ip_pool_rules", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.string "uuid"
+ t.string "owner_type"
+ t.integer "owner_id"
+ t.integer "ip_pool_id"
+ t.text "from_text", limit: 65535
+ t.text "to_text", limit: 65535
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ create_table "ip_pools", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.string "name"
+ t.string "uuid"
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ t.boolean "default", default: false
+ t.string "type"
+ t.index ["uuid"], name: "index_ip_pools_on_uuid", length: { uuid: 8 }, using: :btree
+ end
+
+ create_table "organization_ip_pools", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "organization_id"
+ t.integer "ip_pool_id"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ create_table "organization_users", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "organization_id"
+ t.integer "user_id"
+ t.datetime "created_at", precision: 6
+ t.boolean "admin", default: false
+ t.boolean "all_servers", default: true
+ t.string "user_type"
+ end
+
+ create_table "organizations", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.string "uuid"
+ t.string "name"
+ t.string "permalink"
+ t.string "time_zone"
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ t.integer "ip_pool_id"
+ t.integer "owner_id"
+ t.datetime "deleted_at", precision: 6
+ t.datetime "suspended_at", precision: 6
+ t.string "suspension_reason"
+ t.index ["permalink"], name: "index_organizations_on_permalink", length: { permalink: 8 }, using: :btree
+ t.index ["uuid"], name: "index_organizations_on_uuid", length: { uuid: 8 }, using: :btree
+ end
+
+ create_table "queued_messages", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "server_id"
+ t.integer "message_id"
+ t.string "domain"
+ t.string "locked_by"
+ t.datetime "locked_at", precision: 6
+ t.datetime "retry_after"
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ t.integer "ip_address_id"
+ t.integer "attempts", default: 0
+ t.integer "route_id"
+ t.boolean "manual", default: false
+ t.string "batch_key"
+ t.index ["domain"], name: "index_queued_messages_on_domain", length: { domain: 8 }, using: :btree
+ t.index ["message_id"], name: "index_queued_messages_on_message_id", using: :btree
+ t.index ["server_id"], name: "index_queued_messages_on_server_id", using: :btree
+ end
+
+ create_table "routes", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.string "uuid"
+ t.integer "server_id"
+ t.integer "domain_id"
+ t.integer "endpoint_id"
+ t.string "endpoint_type"
+ t.string "name"
+ t.string "spam_mode"
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ t.string "token"
+ t.string "mode"
+ t.index ["token"], name: "index_routes_on_token", length: { token: 6 }, using: :btree
+ end
+
+ create_table "servers", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "organization_id"
+ t.string "uuid"
+ t.string "name"
+ t.string "mode"
+ t.integer "ip_pool_id"
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ t.string "permalink"
+ t.integer "send_limit"
+ t.datetime "deleted_at", precision: 6
+ t.integer "message_retention_days"
+ t.integer "raw_message_retention_days"
+ t.integer "raw_message_retention_size"
+ t.boolean "allow_sender", default: false
+ t.string "token"
+ t.datetime "send_limit_approaching_at", precision: 6
+ t.datetime "send_limit_approaching_notified_at", precision: 6
+ t.datetime "send_limit_exceeded_at", precision: 6
+ t.datetime "send_limit_exceeded_notified_at", precision: 6
+ t.decimal "spam_threshold", precision: 8, scale: 2
+ t.decimal "spam_failure_threshold", precision: 8, scale: 2
+ t.string "postmaster_address"
+ t.datetime "suspended_at", precision: 6
+ t.decimal "outbound_spam_threshold", precision: 8, scale: 2
+ t.text "domains_not_to_click_track", limit: 65535
+ t.string "suspension_reason"
+ t.boolean "log_smtp_data", default: false
+ t.index ["organization_id"], name: "index_servers_on_organization_id", using: :btree
+ t.index ["permalink"], name: "index_servers_on_permalink", length: { permalink: 6 }, using: :btree
+ t.index ["token"], name: "index_servers_on_token", length: { token: 6 }, using: :btree
+ t.index ["uuid"], name: "index_servers_on_uuid", length: { uuid: 8 }, using: :btree
+ end
+
+ create_table "smtp_endpoints", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "server_id"
+ t.string "uuid"
+ t.string "name"
+ t.string "hostname"
+ t.string "ssl_mode"
+ t.integer "port"
+ t.text "error", limit: 65535
+ t.datetime "disabled_until", precision: 6
+ t.datetime "last_used_at", precision: 6
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ end
+
+ create_table "statistics", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.bigint "total_messages", default: 0
+ t.bigint "total_outgoing", default: 0
+ t.bigint "total_incoming", default: 0
+ end
+
+ create_table "track_certificates", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.string "domain"
+ t.text "certificate", limit: 65535
+ t.text "intermediaries", limit: 65535
+ t.text "key", limit: 65535
+ t.datetime "expires_at"
+ t.datetime "renew_after"
+ t.string "verification_path"
+ t.string "verification_string"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["domain"], name: "index_track_certificates_on_domain", length: { domain: 8 }, using: :btree
+ end
+
+ create_table "track_domains", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.string "uuid"
+ t.integer "server_id"
+ t.integer "domain_id"
+ t.string "name"
+ t.datetime "dns_checked_at"
+ t.string "dns_status"
+ t.string "dns_error"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.boolean "ssl_enabled", default: true
+ t.boolean "track_clicks", default: true
+ t.boolean "track_loads", default: true
+ t.text "excluded_click_domains", limit: 65535
+ end
+
+ create_table "user_invites", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.string "uuid"
+ t.string "email_address"
+ t.datetime "expires_at", precision: 6
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ t.index ["uuid"], name: "index_user_invites_on_uuid", length: { uuid: 12 }, using: :btree
+ end
+
+ create_table "users", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.string "uuid"
+ t.string "first_name"
+ t.string "last_name"
+ t.string "email_address"
+ t.string "password_digest"
+ t.string "time_zone"
+ t.string "email_verification_token"
+ t.datetime "email_verified_at"
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ t.string "password_reset_token"
+ t.datetime "password_reset_token_valid_until"
+ t.boolean "admin", default: false
+ t.index ["email_address"], name: "index_users_on_email_address", length: { email_address: 8 }, using: :btree
+ t.index ["uuid"], name: "index_users_on_uuid", length: { uuid: 8 }, using: :btree
+ end
+
+ create_table "webhook_events", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "webhook_id"
+ t.string "event"
+ t.datetime "created_at", precision: 6
+ t.index ["webhook_id"], name: "index_webhook_events_on_webhook_id", using: :btree
+ end
+
+ create_table "webhook_requests", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "server_id"
+ t.integer "webhook_id"
+ t.string "url"
+ t.string "event"
+ t.string "uuid"
+ t.text "payload", limit: 65535
+ t.integer "attempts", default: 0
+ t.datetime "retry_after", precision: 6
+ t.text "error", limit: 65535
+ t.datetime "created_at", precision: 6
+ end
+
+ create_table "webhooks", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
+ t.integer "server_id"
+ t.string "uuid"
+ t.string "name"
+ t.string "url"
+ t.datetime "last_used_at"
+ t.boolean "all_events", default: false
+ t.boolean "enabled", default: true
+ t.boolean "sign", default: true
+ t.datetime "created_at", precision: 6
+ t.datetime "updated_at", precision: 6
+ t.index ["server_id"], name: "index_webhooks_on_server_id", using: :btree
+ end
+
+end
diff --git a/db/seeds.rb b/db/seeds.rb
new file mode 100644
index 0000000..6fe14fc
--- /dev/null
+++ b/db/seeds.rb
@@ -0,0 +1,10 @@
+ip_pool = IPPool.create!(:name => "Shared IP Pool", :type => 'Transactional', :default => true)
+ip_pool.ip_addresses.create!(:ipv4 => "10.1.1.1", :ipv6 => "2a03:1234:a:1::1", :hostname => "i2.mx.example.com")
+ip_pool.ip_addresses.create!(:ipv4 => "10.1.1.2", :ipv6 => "2a03:1234:a:1::2", :hostname => "i3.mx.example.com")
+
+user = User.create!(:first_name => "Example", :last_name => "Admin", :email_address => "admin@example.com", :password => "llamafarm", :time_zone => "London", :email_verified_at => Time.now)
+
+org = Organization.create!(:name => "Acme Inc", :permalink => "acme", :time_zone => "London", :owner => user)
+org.users << user
+
+server = Server.create!(:ip_pool => ip_pool, :organization => org, :name => "Example Server", :permalink => "example", :mode => "Live")
diff --git a/lib/assets/.keep b/lib/assets/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/lib/postal/app_logger.rb b/lib/postal/app_logger.rb
new file mode 100644
index 0000000..9e97b84
--- /dev/null
+++ b/lib/postal/app_logger.rb
@@ -0,0 +1,52 @@
+require 'logger'
+
+module Postal
+ class AppLogger < Logger
+
+
+ def self.greylog_notifier
+ @greylog_notifier ||= Postal.config.logging.greylog ? GELF::Notifier.new(Postal.config.logging.greylog.host, Postal.config.logging.greylog.port) : nil
+
+ end
+
+ def initialize(log_name, *args)
+ @log_name = log_name
+ super(*args)
+ self.formatter = LogFormatter.new
+ end
+
+ def add(severity, message = nil, progname = nil)
+ super
+ if n = self.class.greylog_notifier
+ begin
+ if message.nil?
+ message = block_given? ? yield : progname
+ end
+ message = message.to_s.force_encoding('UTF-8').scrub
+ message_without_ansi = message.gsub(/\e\[([\d\;]+)?m/, '') rescue message
+ n.notify!(:short_message => message_without_ansi, :log_name => @log_name, :facility => 'postal', :application_name => 'postal', :process_name => ENV['PROC_NAME'], :pid => Process.pid)
+ rescue => e
+ # Can't log this to GELF. Soz.
+ Raven.capture_exception(e)
+ end
+ end
+ true
+ end
+ end
+
+ class LogFormatter
+ TIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%3N".freeze
+ COLORS = [32,34,35,31,32,33]
+
+ def call(severity, datetime, progname, msg)
+ time = datetime.strftime(TIME_FORMAT)
+ if number = ENV['PROC_NAME']
+ id = number.split('.').last.to_i
+ proc_text = "\e[#{COLORS[id % COLORS.size]}m[#{ENV['PROC_NAME']}:#{Process.pid}]\e[0m"
+ else
+ proc_text = "[#{Process.pid}]"
+ end
+ "#{proc_text} [#{time}] #{severity} -- : #{msg}\n"
+ end
+ end
+end
diff --git a/lib/postal/bounce_message.rb b/lib/postal/bounce_message.rb
new file mode 100644
index 0000000..6f1251c
--- /dev/null
+++ b/lib/postal/bounce_message.rb
@@ -0,0 +1,55 @@
+module Postal
+ class BounceMessage
+
+ def initialize(server, message)
+ @server = server
+ @message = message
+ end
+
+ def raw_message
+ mail = Mail.new
+ mail.to = @message.mail_from
+ mail.from = "Mail Delivery Service <#{@message.route.description}>"
+ mail.subject = "Mail Delivery Failed (#{@message.subject})"
+ mail.text_part = body
+ mail.attachments['Original Message.eml'] = {:mime_type => 'message/rfc822', :encoding => 'quoted-printable', :content => @message.raw_message}
+ mail.message_id = "<#{SecureRandom.uuid}@#{Postal.config.dns.return_path}>"
+ mail.to_s
+ end
+
+ def queue
+ message = @server.message_db.new_message
+ message.scope = 'outgoing'
+ message.rcpt_to = @message.mail_from
+ message.mail_from = @message.route.description
+ message.domain_id = @message.domain&.id
+ message.raw_message = self.raw_message
+ message.bounce = 1
+ message.bounce_for_id = @message.id
+ message.save
+ message.id
+ end
+
+ def postmaster_address
+ @server.postmaster_address || "postmaster@#{@message.domain&.name || Postal.config.web.host}"
+ end
+
+ private
+
+ def body
+ <<-BODY.strip_heredoc
+ This is the mail delivery service responsible for delivering mail to #{@message.route.description}.
+
+ The message you've sent cannot be delivered. Your original message is attached to this message.
+
+ For further assistance please contact #{postmaster_address}. Please include the details below to help us identify the issue.
+
+ Message Token: #{@message.token}@#{@server.token}
+ Orginal Message ID: #{@message.message_id}
+ Mail from: #{@message.mail_from}
+ Rcpt To: #{@message.rcpt_to}
+ BODY
+ end
+
+ end
+end
diff --git a/lib/postal/config.rb b/lib/postal/config.rb
new file mode 100644
index 0000000..d95a3b2
--- /dev/null
+++ b/lib/postal/config.rb
@@ -0,0 +1,162 @@
+require 'yaml'
+require 'pathname'
+require_relative 'error'
+require_relative 'version'
+
+module Postal
+
+ def self.host
+ @host ||= config.web.host || "localhost:5000"
+ end
+
+ def self.protocol
+ @protocol ||= config.web.protocol || "http"
+ end
+
+ def self.host_with_protocol
+ @host_with_protocol ||= "#{protocol}://#{host}"
+ end
+
+ def self.app_root
+ @app_root ||= Pathname.new(File.expand_path('../../../', __FILE__))
+ end
+
+ def self.config
+ @config ||= begin
+ require 'hashie/mash'
+ Hashie::Mash.new(yaml_config)
+ end
+ end
+
+ def self.config_root
+ @config_root ||= begin
+ if __FILE__ =~ /\A\/opt\/postal/
+ Pathname.new("/opt/postal/config")
+ elsif ENV['AM_CONFIG_ROOT']
+ Pathname.new(ENV['AM_CONFIG_ROOT'])
+ else
+ Pathname.new(File.expand_path("../../../config", __FILE__))
+ end
+ end
+ end
+
+ def self.config_file_path
+ @config_file_path ||= File.join(config_root, 'postal.yml')
+ end
+
+ def self.yaml_config
+ @yaml_config ||= File.exist?(config_file_path) ? YAML.load_file(config_file_path) : {}
+ end
+
+ def self.database_url
+ if config.main_db
+ "mysql2://#{config.main_db.username}:#{config.main_db.password}@#{config.main_db.host}:#{config.main_db.port}/#{config.main_db.database}?encoding=#{config.main_db.encoding || 'utf8mb4'}"
+ else
+ "mysql2://root@localhost/postal"
+ end
+ end
+
+ def self.logger_for(name)
+ @loggers ||= {}
+ @loggers[name.to_sym] ||= begin
+ require 'postal/app_logger'
+ if config.logging.stdout || ENV['LOG_TO_STDOUT']
+ Postal::AppLogger.new(name, STDOUT)
+ else
+ Postal::AppLogger.new(name, app_root.join('log', "#{name}.log"), config.logging.max_log_files || 10, (config.logging.max_log_file_size || 20).megabytes)
+ end
+ end
+ end
+
+ def self.process_name
+ @process_name ||= begin
+ string = "host:#{Socket.gethostname} pid:#{Process.pid}"
+ string += " procname:#{ENV['PROC_NAME']}" if ENV['PROC_NAME']
+ string
+ rescue
+ "pid:#{Process.pid}"
+ end
+ end
+
+ def self.locker_name
+ string = process_name.dup
+ string += " job:#{Thread.current[:job_id]}" if Thread.current[:job_id]
+ string
+ end
+
+ def self.smtp_from_name
+ config.smtp&.from_name || "Postal"
+ end
+
+ def self.smtp_from_address
+ config.smtp&.from_address || "postal@example.com"
+ end
+
+ def self.smtp_private_key
+ @smtp_private_key ||= OpenSSL::PKey::RSA.new(File.read(smtp_private_key_path))
+ end
+
+ def self.smtp_private_key_path
+ config_root.join('smtp.key')
+ end
+
+ def self.smtp_certificate_path
+ config_root.join('smtp.cert')
+ end
+
+ def self.smtp_certificate_data
+ @smtp_certificate_data ||= File.read(smtp_certificate_path)
+ end
+
+ def self.smtp_certificates
+ @smtp_certificates ||= begin
+ certs = self.smtp_certificate_data.scan(/-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----/m)
+ certs.map do |c|
+ OpenSSL::X509::Certificate.new(c)
+ end.freeze
+ end
+ end
+
+ def self.lets_encrypt_private_key_path
+ @lets_encrypt_private_key_path ||= Postal.config_root.join('lets_encrypt.pem')
+ end
+
+ def self.signing_key_path
+ config_root.join('signing.key')
+ end
+
+ def self.signing_key
+ @signing_key ||= OpenSSL::PKey::RSA.new(File.read(signing_key_path))
+ end
+
+ def self.amrp_dkim_dns_record
+ public_key = signing_key.public_key.to_s.gsub(/\-+[A-Z ]+\-+\n/, '').gsub(/\n/, '')
+ "v=DKIM1; t=s; h=sha256; p=#{public_key};"
+ end
+
+ class ConfigError < Postal::Error
+ end
+
+ def self.check_config!
+ unless File.exist?(self.config_file_path)
+ raise ConfigError, "No config found at #{self.config_file_path}"
+ end
+
+ unless File.exist?(self.smtp_private_key_path)
+ raise ConfigError, "No SMTP private key found at #{self.smtp_private_key_path}"
+ end
+
+ unless File.exist?(self.smtp_certificate_path)
+ raise ConfigError, "No SMTP certificate found at #{self.smtp_certificate_path}"
+ end
+
+ unless File.exists?(self.lets_encrypt_private_key_path)
+ raise ConfigError, "No Let's Encrypt private key found at #{self.lets_encrypt_private_key_path}"
+ end
+
+ unless File.exists?(self.signing_key_path)
+ raise ConfigError, "No signing key found at #{self.signing_key_path}"
+ end
+ end
+
+end
diff --git a/lib/postal/countries.rb b/lib/postal/countries.rb
new file mode 100644
index 0000000..1cd0944
--- /dev/null
+++ b/lib/postal/countries.rb
@@ -0,0 +1,5 @@
+module Postal
+ module Countries
+ NAMES = ["Afghanistan", "Aland Islands", "Albania", "Algeria", "American Samoa", "Andorra", "Angola", "Anguilla", "Antarctica", "Antigua and Barbuda", "Argentina", "Armenia", "Aruba", "Australia", "Austria", "Azerbaijan", "Bahamas", "Bahrain", "Bangladesh", "Barbados", "Belarus", "Belgium", "Belize", "Benin", "Bermuda", "Bhutan", "Bolivia, Plurinational State of", "Bosnia and Herzegovina", "Botswana", "Bouvet Island", "Brazil", "British Indian Ocean Territory", "Brunei Darussalam", "Bulgaria", "Burkina Faso", "Burundi", "Cambodia", "Cameroon", "Canada", "Cape Verde", "Cayman Islands", "Central African Republic", "Chad", "Chile", "China", "Christmas Island", "Cocos (keeling) Islands", "Colombia", "Comoros", "Congo", "Congo, the Democratic Republic of the", "Cook Islands", "Costa Rica", "Cote D'ivoire", "Croatia", "Cuba", "Cyprus", "Czech Republic", "Denmark", "Djibouti", "Dominica", "Dominican Republic", "Ecuador", "Egypt", "El Salvador", "Equatorial Guinea", "Eritrea", "Estonia", "Ethiopia", "Falkland Islands (malvinas)", "Faroe Islands", "Fiji", "Finland", "France", "French Guiana", "French Polynesia", "French Southern Territories", "Gabon", "Gambia", "Georgia", "Germany", "Ghana", "Gibraltar", "Greece", "Greenland", "Grenada", "Guadeloupe", "Guam", "Guatemala", "Guernsey", "Guinea", "Guinea-bissau", "Guyana", "Haiti", "Heard Island and Mcdonald Islands", "Holy See (Vatican City State)", "Honduras", "Hong Kong", "Hungary", "Iceland", "India", "Indonesia", "Iran, Islamic Republic of", "Iraq", "Ireland", "Isle of Man", "Israel", "Italy", "Jamaica", "Japan", "Jersey", "Jordan", "Kazakhstan", "Kenya", "Kiribati", "Korea, Democratic People's Republic of", "Korea, Republic of", "Kuwait", "Kyrgyzstan", "Lao People's Democratic Republic", "Latvia", "Lebanon", "Lesotho", "Liberia", "Libyan Arab Jamahiriya", "Liechtenstein", "Lithuania", "Luxembourg", "Macao", "Macedonia, the Former Yugoslav Republic of", "Madagascar", "Malawi", "Malaysia", "Maldives", "Mali", "Malta", "Marshall Islands", "Martinique", "Mauritania", "Mauritius", "Mayotte", "Mexico", "Micronesia, Federated States of", "Moldova, Republic of", "Monaco", "Mongolia", "Montenegro", "Montserrat", "Morocco", "Mozambique", "Myanmar", "Namibia", "Nauru", "Nepal", "Netherlands", "Netherlands Antilles", "New Caledonia", "New Zealand", "Nicaragua", "Niger", "Nigeria", "Niue", "Norfolk Island", "Northern Mariana Islands", "Norway", "Oman", "Pakistan", "Palau", "Palestinian Territory, Occupied", "Panama", "Papua New Guinea", "Paraguay", "Peru", "Philippines", "Pitcairn", "Poland", "Portugal", "Puerto Rico", "Qatar", "Reunion", "Romania", "Russian Federation", "Rwanda", "Saint Barthelemy", "Saint Helena, Ascension and Tristan Da Cunha", "Saint Kitts and Nevis", "Saint Lucia", "Saint Martin", "Saint Pierre and Miquelon", "Saint Vincent and the Grenadines", "Samoa", "San Marino", "Sao Tome and Principe", "Saudi Arabia", "Senegal", "Serbia", "Seychelles", "Sierra Leone", "Singapore", "Slovakia", "Slovenia", "Solomon Islands", "Somalia", "South Africa", "South Georgia and the South Sandwich Islands", "Spain", "Sri Lanka", "Sudan", "Suriname", "Svalbard and Jan Mayen", "Swaziland", "Sweden", "Switzerland", "Syrian Arab Republic", "Taiwan", "Tajikistan", "Tanzania, United Republic of", "Thailand", "Timor-leste", "Togo", "Tokelau", "Tonga", "Trinidad and Tobago", "Tunisia", "Turkey", "Turkmenistan", "Turks and Caicos Islands", "Tuvalu", "Uganda", "Ukraine", "United Arab Emirates", "United Kingdom", "United States", "United States Minor Outlying Islands", "Uruguay", "Uzbekistan", "Vanuatu", "Venezuela, Bolivarian Republic of", "Viet Nam", "Virgin Islands, British", "Virgin Islands, U.S.", "Wallis and Futuna", "Western Sahara", "Yemen", "Zambia", "Zimbabwe"]
+ end
+end
diff --git a/lib/postal/dkim_header.rb b/lib/postal/dkim_header.rb
new file mode 100644
index 0000000..dca76f7
--- /dev/null
+++ b/lib/postal/dkim_header.rb
@@ -0,0 +1,87 @@
+module Postal
+ class DKIMHeader
+
+ def initialize(domain, message)
+ if domain && domain.dkim_status == 'OK'
+ @domain_name = domain.name
+ @dkim_key = domain.dkim_key
+ @dkim_identifier = domain.dkim_identifier
+ else
+ @domain_name = Postal.config.dns.return_path
+ @dkim_key = Postal.signing_key
+ @dkim_identifier = 'postal'
+ end
+ @domain = domain
+ @message = message
+ @raw_headers, @raw_body = @message.split(/\r?\n\r?\n/, 2)
+ end
+
+ def dkim_header
+ "DKIM-Signature: v=1;" + dkim_properties + signature
+ end
+
+ private
+
+ def headers
+ @headers ||= @raw_headers.to_s.gsub(/\r?\n\s/, ' ').split(/\r?\n/)
+ end
+
+ def header_names
+ normalized_headers.map{ |h| h.split(':')[0].strip }
+ end
+
+ def normalized_headers
+ Array.new.tap do |new_headers|
+ headers.select { |h| h.match(/^(to|from|date|subject|message-id):/i) }.each do |h|
+ new_headers << normalize_header(h)
+ end
+ end
+ end
+
+ def normalize_header(content)
+ content.gsub!(/[ \t]+/, ' ') # Tidy whitespace
+ key, value = content.split(':', 2).map{ |a| a.strip } # Split into key/value and strip whitespace
+ key.downcase! # Downcase the key
+ key + ':' + value # Rejoin
+ end
+
+ def normalized_body
+ @normalized_body ||= begin
+ content = @raw_body.dup
+ content.gsub!("\r", '') # Make sure we have no random CRs
+ content.gsub!("\n", "\r\n") # Convert to CRLF
+ content.gsub!(/[ \t]+/, ' ') # Tidy whitespace
+ content.gsub!(" \r\n", "\r\n") # Remove trailing whitespace
+ content.gsub!(/(\r\n)+\z/, "") # Remove trailing lines
+ content.gsub!(/\z/, "\r\n") # Add a final newline
+ content
+ end
+ end
+
+ def body_hash
+ @body_hash ||= Base64.encode64(Digest::SHA256.digest(normalized_body)).strip
+ end
+
+ def dkim_properties
+ String.new.tap do |header|
+ header << " a=rsa-sha256; c=relaxed/relaxed;"
+ header << " d=#{@domain_name}; s=#{@dkim_identifier}; t=#{Time.now.utc.to_i};"
+ header << " bh=#{body_hash}; h=#{header_names.join(':')};"
+ header << " b="
+ end
+ end
+
+ def dkim_header_for_signing
+ "dkim-signature:v=1;" + dkim_properties
+ end
+
+ def signable_header_string
+ (normalized_headers + [dkim_header_for_signing]).join("\r\n")
+ end
+
+ def signature
+ Base64.encode64(@dkim_key.sign(OpenSSL::Digest::SHA256.new, signable_header_string)).gsub("\n", '')
+ end
+
+ end
+end
diff --git a/lib/postal/error.rb b/lib/postal/error.rb
new file mode 100644
index 0000000..6fc2450
--- /dev/null
+++ b/lib/postal/error.rb
@@ -0,0 +1,7 @@
+module Postal
+ module Errors
+ end
+
+ class Error < StandardError
+ end
+end
diff --git a/lib/postal/espect.rb b/lib/postal/espect.rb
new file mode 100644
index 0000000..b0ea181
--- /dev/null
+++ b/lib/postal/espect.rb
@@ -0,0 +1,55 @@
+module Postal
+ class Espect
+
+ def self.inspect(message, scope = :incoming)
+ if Postal.config.espect&.hosts
+ hosts = Postal.config.espect.hosts.dup.shuffle
+ hosts.each do |host|
+ result = Postal::HTTP.post("#{host}/inspect", :text_body => Base64.encode64(message), :timeout => 20)
+ if result[:code] == 200 && json = (JSON.parse(result[:body]) rescue nil)
+ return EspectResult.new(json, scope)
+ end
+ end
+ nil
+ end
+ end
+
+ end
+
+ class EspectResult
+
+ EXCLUSIONS = {
+ :outgoing => ['NO_RECEIVED', 'NO_RELAYS', 'ALL_TRUSTED', 'FREEMAIL_FORGED_REPLYTO', 'RDNS_DYNAMIC', /^SPF\_/, /^HELO\_/, /DKIM_/, /^RCVD_IN_/],
+ :incoming => []
+ }
+
+ def initialize(reply, scope)
+ @reply = reply
+ @scope = scope
+ end
+
+ def spam_score
+ @spam_score ||= begin
+ spam_details.inject(0.0) do |total, detail|
+ total += detail['score'] || 0.0
+ end
+ end
+ end
+
+ def spam_details
+ @spam_details ||= (@reply['spam_details'] || []).reject do |d|
+ EXCLUSIONS[@scope].any? do |item|
+ item == d['code'] || (item.is_a?(Regexp) && item =~ d['code'])
+ end
+ end
+ end
+
+ def threat?
+ @reply['threat'] ? true : false
+ end
+
+ def threat_message
+ @reply['threat_message']
+ end
+ end
+end
diff --git a/lib/postal/fast_server/client.rb b/lib/postal/fast_server/client.rb
new file mode 100644
index 0000000..1fde9af
--- /dev/null
+++ b/lib/postal/fast_server/client.rb
@@ -0,0 +1,168 @@
+require 'stringio'
+
+module Postal
+ module FastServer
+ class Client
+ class ClientWentAway < StandardError; end
+ class BadRequest < StandardError; end
+
+ def initialize(socket, options)
+ @raw_socket = socket
+ @options = options
+ end
+
+ def run
+ Timeout.timeout(15) do
+ if Postal.config.fast_server.proxy_protocol
+
+ # gets without readahead
+ line = ""
+ char = nil
+ while(char != "\n")
+ char = @raw_socket.read(1)
+ line << char
+ end
+ line.chomp!
+
+ if m = line.match(/\APROXY (.+) (.+) (.+) (.+) (.+)\z/)
+ @remote_ip = m[2]
+ else
+ return false
+ end
+ end
+
+ if self.ssl?
+ @socket = OpenSSL::SSL::SSLSocket.new(@raw_socket, self.class.ssl_context)
+ @socket.accept
+ else
+ @socket = @raw_socket
+ end
+
+ Timeout::timeout(20) do
+ # Read the request line
+ request = @socket.gets.to_s.chomp
+ # Split the request into its 3 parts
+ method, path, protocol = request.split(' ', 3)
+
+ raise BadRequest unless method && path && protocol
+
+ # Create an empty header set
+ header_set = HTTPHeaderSet.new
+ # Read each header and populate the header set
+ loop do
+ header = @socket.gets
+ if header.nil?
+ raise ClientWentAway
+ elsif header.chomp == ""
+ break
+ else
+ header_set << HTTPHeader.from_string(header.chomp)
+ end
+ end
+
+ # At this point, one might want to read the request body, but I don't think we need it.
+
+ # Build rack request
+ server_name, server_port = header_set['Host'].try(:value).to_s.split(":", 2)
+ request = {
+ "REQUEST_METHOD" => method,
+ "SCRIPT_NAME" => "",
+ "PATH_INFO" => path.split('?', 2)[0],
+ "QUERY_STRING" => path.split('?', 2)[1],
+ "SERVER_NAME" => server_name || "",
+ "SERVER_PORT" => server_name || "",
+ "rack.version" => [1, 3],
+ "rack.url_scheme" => ssl? ? "https" : "http",
+ "rack.input" => StringIO.new(""),
+ "rack.errors" => STDERR,
+ "rack.multithread" => true,
+ "rack.multiprocess" => true,
+ "rack.run_once" => false,
+ "rack.hijack" => false,
+ "rack.hijack_io" => false,
+ "REMOTE_ADDR" => remote_ip,
+ }
+
+ # Add request headers to rack hash
+ header_set.headers.each do |header|
+ request["HTTP_" + header.key.gsub('-', '_').upcase] = header.value
+ end
+
+ # Call the rack app and process the result
+ code, headers, body = Interface.new.call(request)
+ response = "HTTP/1.1 #{code} #{Rack::Utils::HTTP_STATUS_CODES[code]}\r\n"
+ headers.each do |k,v|
+ response << "#{k}:#{v}\r\n"
+ end
+ response << "\r\n"
+ body.each do |data|
+ response << data
+ end
+ @socket.write(response)
+ end
+ end
+ rescue ClientWentAway, Timeout::Error, Errno::ECONNRESET
+ # We don't really care if a client has disapeared, close the sockets and carry on.
+ rescue OpenSSL::SSL::SSLError
+ # Don't worry about SSL negotiation failures, disconnect and carry on
+ rescue BadRequest
+ # We couldn't read a proper HTTP request, disconnect the client
+ rescue => e
+ Raven.capture_exception(e)
+ ensure
+ @socket.close rescue nil
+ @raw_socket.close rescue nil
+ end
+
+ def ssl?
+ !!@options[:ssl]
+ end
+
+ def remote_ip
+ @remote_ip || @raw_socket.peeraddr[3].sub('::ffff:', '')
+ end
+
+ def self.ssl_context(domain_name = nil)
+ @ssl_certificates ||= {}
+ unless @ssl_certificates_refreshed && @ssl_certificates_refreshed > Time.now.utc.beginning_of_day
+ @ssl_certificates_refreshed = Time.now.utc
+ @ssl_certificates = {}
+ end
+ @ssl_certificates[domain_name] ||= OpenSSL::SSL::SSLContext.new.tap do |ssl_context|
+ if domain_name
+ if domain = TrackCertificate.active.where(:domain => domain_name).first
+ ssl_context.cert = domain.certificate_object
+ ssl_context.extra_chain_cert = domain.intermediaries_array
+ ssl_context.key = domain.key_object
+ end
+ end
+
+ if ssl_context.cert.nil?
+ certs = Postal.ssl_certificates
+ ssl_context.cert = certs.shift
+ ssl_context.extra_chain_cert = certs
+ ssl_context.key = Postal.signing_key
+ end
+
+ ssl_context.ssl_version = "SSLv23"
+ ssl_context.ciphers = 'EECDH+ECDSA+AESGCM EECDH+aRSA+AESGCM EECDH+ECDSA+SHA384 EECDH+ECDSA+SHA256 EECDH+aRSA+SHA384 EECDH+aRSA+SHA256 EECDH+aRSA+RC4 EECDH EDH+aRSA !aNULL !eNULL !LOW !3DES !MD5 !EXP !PSK !SRP !DSS !RC4 !DH'
+ ssl_context.options = OpenSSL::SSL::SSLContext::DEFAULT_PARAMS[:options] |
+ OpenSSL::SSL::OP_NO_SSLv2 |
+ OpenSSL::SSL::OP_NO_SSLv3 |
+ OpenSSL::SSL::OP_NO_COMPRESSION |
+ OpenSSL::SSL::OP_CIPHER_SERVER_PREFERENCE
+ ssl_context.tmp_ecdh_callback = Proc.new do |*a|
+ OpenSSL::PKey::EC.new("prime256v1")
+ end
+
+ unless domain_name
+ ssl_context.servername_cb = Proc.new do |ctx, hostname|
+ self.ssl_context(hostname)
+ end
+ end
+ end
+ end
+
+ end
+ end
+end
diff --git a/lib/postal/fast_server/http_header.rb b/lib/postal/fast_server/http_header.rb
new file mode 100644
index 0000000..c2d086f
--- /dev/null
+++ b/lib/postal/fast_server/http_header.rb
@@ -0,0 +1,21 @@
+module Postal
+ module FastServer
+ class HTTPHeader
+ attr_accessor :key, :value
+ def self.from_string(string)
+ k, v = string.to_s.split(/\:\s*/, 2)
+ self.new(k.to_s, v.to_s)
+ end
+
+ def initialize(k, v)
+ @key = k
+ @value = v
+ end
+
+ def to_s
+ @key + ": " + @value
+ end
+ end
+
+ end
+end
diff --git a/lib/postal/fast_server/http_header_set.rb b/lib/postal/fast_server/http_header_set.rb
new file mode 100644
index 0000000..cb3e1a8
--- /dev/null
+++ b/lib/postal/fast_server/http_header_set.rb
@@ -0,0 +1,37 @@
+module Postal
+ module FastServer
+ class HTTPHeaderSet
+ attr_accessor :headers
+ def initialize
+ @headers = []
+ end
+
+ def self.from_string_array(array)
+ header_set = self.new
+ header_set.headers = array.map{|h|HTTPHeader.from_string(h)}
+ header_set
+ end
+
+ def select(key)
+ @headers.select{|h|h.key.downcase == key.downcase}
+ end
+
+ def [](key)
+ @headers.find{|h|h.key.downcase == key.downcase}
+ end
+
+ def []=(key, value)
+ self.delete(key)
+ @headers << HTTPHeader.new(key, value)
+ end
+
+ def delete(key)
+ @headers.delete_if{|h|h.key.downcase == key.downcase}
+ end
+
+ def <<(header)
+ @headers << header
+ end
+ end
+ end
+end
diff --git a/lib/postal/fast_server/interface.rb b/lib/postal/fast_server/interface.rb
new file mode 100644
index 0000000..e39acdf
--- /dev/null
+++ b/lib/postal/fast_server/interface.rb
@@ -0,0 +1,92 @@
+module Postal
+ module FastServer
+ class Interface
+
+ # TODO: Make this multithreaded? Thread-safe?
+
+ TRACKING_PIXEL = File.read(Rails.root.join('app', 'assets', 'images', 'tracking_pixel.png'))
+
+ def get_message_db_from_server_token(token)
+ if server = ::Server.find_by_token(token)
+ server.message_db
+ else
+ nil
+ end
+ end
+
+ def call(env)
+ request = Rack::Request.new(env)
+
+ if request.path =~ /\A\/(\.well-known\/.*)/
+ if certificate = ::TrackCertificate.find_by_verification_path($1)
+ return [200, {'Content-Length' => certificate.verification_string.bytesize.to_s}, [certificate.verification_string]]
+ else
+ return [404, {}, ["Verification not found"]]
+ end
+
+ elsif request.path =~ /\A\/img\/([a-z0-9\-]+)\/([a-z0-9\-]+)/i
+ server_token = $1
+ message_token = $2
+
+ if message_db = get_message_db_from_server_token(server_token)
+ begin
+ message = message_db.message(:token => message_token)
+ message.create_load(request)
+ rescue Postal::MessageDB::Message::NotFound
+ # This message has been removed, we'll just continue to serve the image
+ rescue => e
+ # Somethign else went wrong. We don't want to stop the image loading though because
+ # this is our problem. Log this exception though.
+ Raven.capture_exception(e)
+ end
+ source_image = request.params['src']
+ if source_image.nil?
+ headers = {}
+ headers['Content-Type'] = "image/png"
+ headers['Content-Length'] = TRACKING_PIXEL.bytesize.to_s
+ return [200, headers, [TRACKING_PIXEL]]
+ elsif source_image =~ /\Ahttps?\:\/\//
+ response = Postal::HTTP.get(source_image, :timeout => 3)
+ if response[:code] == 200
+ headers = {}
+ headers['Content-Type'] = response[:headers]['content-type']&.first
+ headers['Last-Modified'] = response[:headers]['last-modified']&.first
+ headers['Cache-Control'] = response[:headers]['cache-control']&.first
+ headers['Etag'] = response[:headers]['etag']&.first
+ headers['Content-Length'] = response[:body].bytesize.to_s
+ return [200, headers, [response[:body]]]
+ else
+ return [404, {}, ['Not found']]
+ end
+ else
+ return [400, {}, ['Invalid/missing source image']]
+ end
+ else
+ return [404, {}, ['Invalid Server Token']]
+ end
+ end
+
+ if request.path =~ /\A\/([a-z0-9\-]+)\/([a-z0-9\-]+)/i
+ server_token = $1
+ link_token = $2
+ if message_db = get_message_db_from_server_token(server_token)
+ if link = message_db.select(:links, :where => {:token => link_token}, :limit => 1).first
+ time = Time.now.to_f
+ message_db.update(:messages, {:clicked => time}, :where => {:id => link['message_id']})
+ message_db.insert(:clicks, {:message_id => link['message_id'], :link_id => link['id'], :ip_address => request.ip, :user_agent => request.user_agent, :timestamp => time})
+ SendWebhookJob.queue(:main, :server_id => message_db.server_id, :event => 'MessageLinkClicked', :payload => {:_message => link['message_id'], :url => link['url'], :token => link['token'], :ip_address => request.ip, :user_agent => request.user_agent})
+ return [307, {'Location' => link['url']}, ["Redirected to: #{link['url']}"]]
+ else
+ return [404, {}, ['Link not found']]
+ end
+ else
+ return [404, {}, ['Invalid Server Token']]
+ end
+ end
+
+ [200, {}, ["Hello."]]
+ end
+
+ end
+ end
+end
diff --git a/lib/postal/fast_server/server.rb b/lib/postal/fast_server/server.rb
new file mode 100644
index 0000000..6473e8d
--- /dev/null
+++ b/lib/postal/fast_server/server.rb
@@ -0,0 +1,37 @@
+require 'socket'
+require 'openssl'
+
+module Postal
+ module FastServer
+ class Server
+
+ def run
+ Thread.abort_on_exception = true
+ TrackCertificate
+ server_sockets = {
+ TCPServer.new(Postal.config.fast_server.bind_address, Postal.config.fast_server.ssl_port) => {:ssl => true},
+ TCPServer.new(Postal.config.fast_server.bind_address, Postal.config.fast_server.port) => {:ssl => false},
+ }
+ Postal.logger_for(:fast_server).info("Fast server started listening on HTTP port #{Postal.config.fast_server.port}")
+ Postal.logger_for(:fast_server).info("Fast server started listening on HTTPS port #{Postal.config.fast_server.ssl_port}")
+ loop do
+ client = nil
+ ios = select(server_sockets.keys, nil, nil, 1)
+ if ios && server_io = ios[0][0]
+ begin
+ client_io = server_io.accept_nonblock
+ client = Client.new(client_io, server_sockets[server_io])
+ Thread.new(client) { |t_client| t_client.run }
+ rescue IO::WaitReadable, Errno::EINTR
+ # Never mind, guess the client went away
+ rescue => e
+ Raven.capture_exception(e)
+ client_io.close rescue nil
+ end
+ end
+ end
+ end
+
+ end
+ end
+end
diff --git a/lib/postal/helpers.rb b/lib/postal/helpers.rb
new file mode 100644
index 0000000..d5bb347
--- /dev/null
+++ b/lib/postal/helpers.rb
@@ -0,0 +1,10 @@
+module Postal
+ module Helpers
+
+ def self.strip_name_from_address(address)
+ return nil if address.nil?
+ address.gsub(/.*, '').gsub(/>.*/, '').strip
+ end
+
+ end
+end
diff --git a/lib/postal/http.rb b/lib/postal/http.rb
new file mode 100644
index 0000000..960e061
--- /dev/null
+++ b/lib/postal/http.rb
@@ -0,0 +1,94 @@
+require 'net/https'
+require 'uri'
+
+module Postal
+ module HTTP
+
+ def self.get(url, options = {})
+ request(Net::HTTP::Get, url, options)
+ end
+
+ def self.post(url, options = {})
+ request(Net::HTTP::Post, url, options)
+ end
+
+ def self.request(method, url, options = {})
+ options[:headers] ||= {}
+ uri = URI.parse(url)
+ request = method.new(uri.path.length == 0 ? "/" : uri.path)
+ options[:headers].each { |k,v| request.add_field k, v }
+
+ if options[:username]
+ request.basic_auth(options[:username], options[:password])
+ end
+
+ if options[:params].is_a?(Hash)
+ # If params has been provided, sent it them as form encoded values
+ request.set_form_data(options[:params])
+
+ elsif options[:json].is_a?(String)
+ # If we have a JSON string, set the content type and body to be the JSON
+ # data
+ request.add_field 'Content-Type', 'application/json'
+ request.body = options[:json]
+
+ elsif options[:text_body]
+ # Add a plain text body if we have one
+ request.body = options[:text_body]
+ end
+
+ if options[:sign]
+ #signature = EncryptoSigno.sign(Postal.signing_key, request.body.to_s).gsub("\n", '')
+ #request.add_field 'X-Postal-Signature', signature
+ end
+
+ request['User-Agent'] = options[:user_agent] || "Postal/#{Postal::VERSION}"
+
+ connection = Net::HTTP.new(uri.host, uri.port)
+
+ if uri.scheme == 'https'
+ connection.use_ssl = true
+ connection.verify_mode = OpenSSL::SSL::VERIFY_PEER
+ ssl = true
+ else
+ ssl = false
+ end
+
+ begin
+ timeout = options[:timeout] || 60
+ Timeout.timeout(timeout) do
+ result = connection.request(request)
+ {
+ :code => result.code.to_i,
+ :body => result.body,
+ :headers => result.to_hash,
+ :secure => @ssl
+ }
+ end
+ rescue OpenSSL::SSL::SSLError => e
+ {
+ :code => -3,
+ :body => "Invalid SSL certificate",
+ :headers =>{},
+ :secure => @ssl
+ }
+ rescue SocketError, Errno::ECONNRESET, EOFError, Errno::EINVAL, Errno::ENETUNREACH, Errno::EHOSTUNREACH, Errno::ECONNREFUSED => e
+ {
+ :code => -2,
+ :body => e.message,
+ :headers => {},
+ :secure => @ssl
+ }
+ rescue Timeout::Error => e
+ {
+ :code => -1,
+ :body => "Timed out after #{timeout}s",
+ :headers => {},
+ :secure => @ssl
+ }
+ end
+ end
+
+ end
+end
+
diff --git a/lib/postal/http_sender.rb b/lib/postal/http_sender.rb
new file mode 100644
index 0000000..b474147
--- /dev/null
+++ b/lib/postal/http_sender.rb
@@ -0,0 +1,125 @@
+module Postal
+ class HTTPSender < Sender
+
+ def initialize(endpoint, options = {})
+ @endpoint = endpoint
+ @options = options
+ @log_id = Nifty::Utils::RandomString.generate(:length => 8).upcase
+ end
+
+ def send_message(message)
+ start_time = Time.now
+ result = SendResult.new
+ result.log_id = @log_id
+
+ request_options = {}
+ request_options[:sign] = true
+ request_options[:timeout] = @endpoint.timeout || 5
+ case @endpoint.encoding
+ when 'BodyAsJSON'
+ request_options[:json] = parameters(message, :flat => false).to_json
+ when 'FormData'
+ request_options[:params] = parameters(message, :flat => true)
+ end
+
+ log "Sending request to #{@endpoint.url}"
+ response = Postal::HTTP.post(@endpoint.url, request_options)
+ result.secure = !!response[:secure]
+ result.details = "Received a #{response[:code]} from #{@endpoint.url}"
+ log " -> Received: #{response[:code]}"
+ if response[:body]
+ log " -> Body: #{response[:body][0,255]}"
+ result.output = response[:body].to_s[0, 500].strip
+ end
+ if response[:code] >= 200 && response[:code] < 300
+ # This is considered a success
+ result.type = 'Sent'
+ elsif response[:code] >= 500 && response[:code] < 600
+ # This is temporary. They might fix their server so it should soft fail.
+ result.type = 'SoftFail'
+ result.retry = true
+ elsif response[:code] < 0
+ # Connection/SSL etc... errors
+ result.type = 'SoftFail'
+ result.retry = true
+ result.connect_error = true
+ else
+ # This is permanent. Any other error isn't cool with us.
+ result.type = 'HardFail'
+ end
+ result.time = (Time.now - start_time).to_f.round(2)
+ result
+ end
+
+ private
+
+ def log(text)
+ Postal.logger_for(:http_sender).info("[#{@log_id}] #{text}")
+ end
+
+ def parameters(message, options = {})
+ case @endpoint.format
+ when 'Hash'
+ hash = {
+ :id => message.id,
+ :rcpt_to => message.rcpt_to,
+ :mail_from => message.mail_from,
+ :token => message.token,
+ :subject => message.subject,
+ :message_id => message.message_id,
+ :timestamp => message.timestamp.to_f,
+ :size => message.size,
+ :spam_status => message.spam_status,
+ :bounce => message.bounce == 1 ? true : false,
+ :received_with_ssl => message.received_with_ssl == 1,
+ :to => message.headers['to']&.last,
+ :cc => message.headers['cc']&.last,
+ :from => message.headers['from']&.last,
+ :date => message.headers['date']&.last,
+ :in_reply_to => message.headers['in-reply-to']&.last,
+ :references => message.headers['references']&.last,
+ :html_body => message.html_body,
+ :attachment_quantity => message.attachments.size
+ }
+
+ if @endpoint.strip_replies
+ hash[:plain_body], hash[:replies_from_plain_body] = Postal::ReplySeparator.separate(message.plain_body)
+ else
+ hash[:plain_body] = message.plain_body
+ end
+
+ if @endpoint.include_attachments?
+ if options[:flat]
+ message.attachments.each_with_index do |a, i|
+ hash["attachments[#{i}][filename]"] = a.filename
+ hash["attachments[#{i}][content_type]"] = a.content_type
+ hash["attachments[#{i}][size]"] = a.body.to_s.bytesize.to_s
+ hash["attachments[#{i}][data]"] = Base64.encode64(a.body.to_s)
+ end
+ else
+ hash[:attachments] = message.attachments.map do |a|
+ {
+ :filename => a.filename,
+ :content_type => a.mime_type,
+ :size => a.body.to_s.bytesize,
+ :data => Base64.encode64(a.body.to_s)
+ }
+ end
+ end
+ end
+
+ hash
+ when 'RawMessage'
+ {
+ :id => message.id,
+ :message => Base64.encode64(message.raw_message),
+ :base64 => true,
+ :size => message.size.to_i
+ }
+ else
+ {}
+ end
+ end
+
+ end
+end
diff --git a/lib/postal/job.rb b/lib/postal/job.rb
new file mode 100644
index 0000000..bf457bd
--- /dev/null
+++ b/lib/postal/job.rb
@@ -0,0 +1,36 @@
+require 'nifty/utils/random_string'
+
+module Postal
+ class Job
+ def initialize(id, params = {})
+ @id = id
+ @params = params.with_indifferent_access
+ end
+
+ def id
+ @id
+ end
+
+ def params
+ @params || {}
+ end
+
+ def perform
+ end
+
+ def log(text)
+ Worker.logger.info "[#{@id}] #{text}"
+ end
+
+ def self.queue(queue, params = {})
+ job_id = Nifty::Utils::RandomString.generate(:length => 10).upcase
+ job_payload = {'params' => params, 'class_name' => self.name, 'id' => job_id, 'queue' => queue}
+ Postal::Worker.job_queue(queue).publish(job_payload.to_json, :persistent => false)
+ job_id
+ end
+
+ def self.perform(params = {})
+ new(nil, params).perform
+ end
+ end
+end
diff --git a/lib/postal/lets_encrypt.rb b/lib/postal/lets_encrypt.rb
new file mode 100644
index 0000000..1f9f12a
--- /dev/null
+++ b/lib/postal/lets_encrypt.rb
@@ -0,0 +1,24 @@
+require 'acme-client'
+
+module Postal
+ module LetsEncrypt
+
+ def self.client
+ @client ||= Acme::Client.new(:private_key => private_key, :endpoint => endpoint)
+ end
+
+ def self.private_key
+ @private_key ||= OpenSSL::PKey::RSA.new(File.open(Postal.lets_encrypt_private_key_path))
+ end
+
+ def self.endpoint
+ @endpoint ||= Rails.env.development? ? "https://acme-staging.api.letsencrypt.org" : "https://acme-v01.api.letsencrypt.org/"
+ end
+
+ def self.register_private_key(email_address)
+ registration = client.register(:contact => "mailto:#{email_address}")
+ registration.agree_terms
+ end
+
+ end
+end
diff --git a/lib/postal/message_db/click.rb b/lib/postal/message_db/click.rb
new file mode 100644
index 0000000..4429c6a
--- /dev/null
+++ b/lib/postal/message_db/click.rb
@@ -0,0 +1,19 @@
+module Postal
+ module MessageDB
+ class Click
+
+ def initialize(attributes, link)
+ @url = link['url']
+ @ip_address = attributes['ip_address']
+ @user_agent = attributes['user_agent']
+ @timestamp = Time.at(attributes['timestamp'])
+ end
+
+ attr_reader :ip_address
+ attr_reader :user_agent
+ attr_reader :timestamp
+ attr_reader :url
+
+ end
+ end
+end
diff --git a/lib/postal/message_db/database.rb b/lib/postal/message_db/database.rb
new file mode 100644
index 0000000..68fdd3e
--- /dev/null
+++ b/lib/postal/message_db/database.rb
@@ -0,0 +1,379 @@
+module Postal
+ module MessageDB
+ class Database
+
+ def initialize(organization_id, server_id)
+ @organization_id = organization_id
+ @server_id = server_id
+ end
+
+ attr_reader :organization_id
+ attr_reader :server_id
+
+ #
+ # Return the server
+ #
+ def server
+ @server ||= Server.find_by_id(@server_id)
+ end
+
+ #
+ # Return the current schema version
+ #
+ def schema_version
+ @schema_version ||= begin
+ last_migration = select(:migrations, :order => :version, :direction => 'DESC', :limit => 1).first
+ last_migration ? last_migration['version'] : 0
+ rescue Mysql2::Error => e
+ e.message =~ /doesn\'t exist/ ? 0 : raise
+ end
+ end
+
+ #
+ # Return a single message. Accepts an ID or an array of conditions
+ #
+ def message(*args)
+ Message.find_one(self, *args)
+ end
+
+ #
+ # Return an array or count of messages.
+ #
+ def messages(*args)
+ Message.find(self, *args)
+ end
+
+ def messages_with_pagination(*args)
+ Message.find_with_pagination(self, *args)
+ end
+
+ #
+ # Create a new message with the given attributes. This won't be saved to the database
+ # until it has been 'save'd.
+ #
+ def new_message(attributes = {})
+ Message.new(self, attributes)
+ end
+
+ #
+ # Return the total size of all stored messages
+ #
+ def total_size
+ query("SELECT SUM(size) AS size FROM `#{database_name}`.`raw_message_sizes`").first['size'] || 0
+ end
+
+ #
+ # Return the live stats instance
+ #
+ def live_stats
+ @live_stats ||= LiveStats.new(self)
+ end
+
+ #
+ # Return the statistics instance
+ #
+ def statistics
+ @statistics ||= Statistics.new(self)
+ end
+
+ #
+ # Return the provisioner instance
+ #
+ def provisioner
+ @provisioner ||= Provisioner.new(self)
+ end
+
+ #
+ # Return the provisioner instance
+ #
+ def suppression_list
+ @suppression_list ||= SuppressionList.new(self)
+ end
+
+ #
+ # Return the provisioner instance
+ #
+ def webhooks
+ @webhooks ||= Webhooks.new(self)
+ end
+
+ #
+ # Return the name for a raw message table for a given date
+ #
+ def raw_table_name_for_date(date)
+ date.strftime("raw-%Y-%m-%d")
+ end
+
+ #
+ # Insert a new raw message into a table (creating it if needed)
+ #
+ def insert_raw_message(data, date = Date.today)
+ table_name = raw_table_name_for_date(date)
+ begin
+ headers, body = data.split(/\r?\n\r?\n/, 2)
+ headers_id = insert(table_name, :data => headers)
+ body_id = insert(table_name, :data => body)
+ rescue Mysql2::Error => e
+ if e.message =~ /doesn\'t exist/
+ provisioner.create_raw_table(table_name)
+ retry
+ else
+ raise
+ end
+ end
+ [table_name, headers_id, body_id]
+ end
+
+ #
+ # Selects entries from the database. Accepts a number of options which can be used
+ # to manipulate the results.
+ #
+ # :where => A hash containing the query
+ # :order => The name of a field to order by
+ # :direction => The order that should be applied to ordering (ASC or DESC)
+ # :fields => An array of fields to select
+ # :limit => Limit the number of results
+ # :page => Which page number to return
+ # :per_page => The number of items per page (defaults to 30)
+ # :count => Return a count of the results instead of the actual data
+ #
+ def select(table, options = {})
+ sql_query = "SELECT"
+ if options[:count]
+ sql_query << " COUNT(id) AS count"
+ elsif options[:fields]
+ sql_query << " " + options[:fields].map { |f| "`#{f}`" }.join(', ')
+ else
+ sql_query << " *"
+ end
+ sql_query << " FROM `#{database_name}`.`#{table}`"
+ if options[:where] && !options[:where].empty?
+ sql_query << " " + build_where_string(options[:where], ' AND ')
+ end
+ if options[:order]
+ direction = (options[:direction] || 'ASC').upcase
+ raise Postal::Error, "Invalid direction #{options[:direction]}" unless ['ASC', 'DESC'].include?(direction)
+ sql_query << " ORDER BY `#{options[:order]}` #{direction}"
+ end
+
+ if options[:limit]
+ sql_query << " LIMIT #{options[:limit]}"
+ end
+
+ if options[:offset]
+ sql_query << " OFFSET #{options[:offset]}"
+ end
+
+ result = query(sql_query)
+ if options[:count]
+ result.first['count']
+ else
+ result.to_a
+ end
+ end
+
+ #
+ # A paginated version of select
+ #
+ def select_with_pagination(table, page, options = {})
+ page = page.to_i
+ page = 1 if page <= 0
+
+ per_page = options.delete(:per_page) || 30
+ offset = (page - 1) * per_page
+
+ result = {}
+ result[:total] = select(table, options.merge(:count => true))
+ result[:records] = select(table, options.merge(:limit => per_page, :offset => offset))
+ result[:per_page] = per_page
+ result[:total_pages], remainder = result[:total].divmod(per_page)
+ result[:total_pages] += 1 if remainder > 0
+ result[:page] = page
+ result
+ end
+
+ #
+ # Updates a record in the database. Accepts a table name, the attributes to update
+ # plus some options which are shown below:
+ #
+ # :where => The condition to apply to the query
+ #
+ # Will return the total number of affected rows.
+ #
+ def update(table, attributes, options = {})
+ sql_query = "UPDATE `#{database_name}`.`#{table}` SET"
+ sql_query << " #{hash_to_sql(attributes)}"
+ if options[:where]
+ sql_query << " " + build_where_string(options[:where])
+ end
+ with_mysql do |mysql|
+ query_on_connection(mysql, sql_query)
+ mysql.affected_rows
+ end
+ end
+
+ #
+ # Insert a record into a given table. A hash of attributes is also provided.
+ # Will return the ID of the new item.
+ #
+ def insert(table, attributes)
+ sql_query = "INSERT INTO `#{database_name}`.`#{table}`"
+ sql_query << " (" + attributes.keys.map { |k| "`#{k}`" }.join(', ') + ")"
+ sql_query << " VALUES (" + attributes.values.map { |v| escape(v) }.join(', ') + ")"
+ with_mysql do |mysql|
+ query_on_connection(mysql, sql_query)
+ mysql.last_id
+ end
+ end
+
+ #
+ # Insert multiple rows at the same time in the same query
+ #
+ def insert_multi(table, keys, values)
+ if values.empty?
+ nil
+ else
+ sql_query = "INSERT INTO `#{database_name}`.`#{table}`"
+ sql_query << " (" + keys.map { |k| "`#{k}`" }.join(', ') + ")"
+ sql_query << " VALUES "
+ sql_query << values.map { |v| "(" + v.map { |v| escape(v) }.join(', ') + ")" }.join(', ')
+ query(sql_query)
+ end
+ end
+
+ #
+ # Deletes a in the database. Accepts a table name, and some options which
+ # are shown below:
+ #
+ # :where => The condition to apply to the query
+ #
+ # Will return the total number of affected rows.
+ #
+ def delete(table, options = {})
+ sql_query = "DELETE FROM `#{database_name}`.`#{table}`"
+ sql_query << " " + build_where_string(options[:where], ' AND ')
+ with_mysql do |mysql|
+ query_on_connection(mysql, sql_query)
+ mysql.affected_rows
+ end
+ end
+
+ #
+ # Return the correct database name
+ #
+ def database_name
+ @database_name ||= "#{Postal.config.message_db.prefix}-server-#{@server_id}"
+ end
+
+ #
+ # Run a query, log it and return the result
+ #
+ class ResultForExplainPrinter
+ attr_reader :columns
+ attr_reader :rows
+ def initialize(result)
+ if result.first
+ @columns = result.first.keys
+ @rows = result.map { |row| row.map(&:last) }
+ else
+ @columns = []
+ @rows = []
+ end
+ end
+ end
+
+ def stringify_keys(hash)
+ hash.each_with_object({}) do |(key, value), hash|
+ hash[key.to_s] = value
+ end
+ end
+
+ def escape(value)
+ with_mysql do |mysql|
+ if value == true
+ '1'
+ elsif value == false
+ '0'
+ elsif value.nil?
+ 'NULL'
+ else
+ if value.to_s.length == 0
+ 'NULL'
+ else
+ "'" + mysql.escape(value.to_s) + "'"
+ end
+ end
+ end
+ end
+
+ def query(query)
+ with_mysql do |mysql|
+ query_on_connection(mysql, query)
+ end
+ end
+
+ private
+
+ def query_on_connection(connection, query)
+ start_time = Time.now.to_f
+ result = connection.query(query)
+ time = Time.now.to_f - start_time
+ logger.debug " \e[4;34mMessageDB Query (#{time.round(2)}s) \e[0m \e[33m#{query}\e[0m"
+ if time > 0.5 && query =~ /\A(SELECT|UPDATE|DELETE) /
+ id = Nifty::Utils::RandomString.generate(:length => 6).upcase
+ explain_result = ResultForExplainPrinter.new(connection.query("EXPLAIN #{query}"))
+ slow_query_logger.info "[#{id}] EXPLAIN #{query}"
+ for line in ActiveRecord::ConnectionAdapters::MySQL::ExplainPrettyPrinter.new.pp(explain_result, time).split("\n")
+ slow_query_logger.info "[#{id}] " + line
+ end
+ end
+ result
+ end
+
+ def logger
+ defined?(Rails) ? Rails.logger : Logger.new(STDOUT)
+ end
+
+ def slow_query_logger
+ Postal.logger_for(:slow_message_db_queries)
+ end
+
+ def with_mysql(&block)
+ MessageDB::MySQL.client(&block)
+ end
+
+ def build_where_string(attributes, joiner = ', ')
+ "WHERE #{hash_to_sql(attributes, joiner)}"
+ end
+
+ def hash_to_sql(hash, joiner = ', ')
+ hash.map do |key, value|
+ if value.is_a?(Array) && value.all? { |v| v.is_a?(Fixnum) }
+ "`#{key}` IN (#{value.join(', ')})"
+ elsif value.is_a?(Array)
+ escaped_values = value.map { |v| escape(v) }.join(', ')
+ "`#{key}` IN (#{escaped_values})"
+ elsif value.is_a?(Hash)
+ sql = []
+ value.each do |operator, value|
+ case operator
+ when :less_than
+ sql << "`#{key}` < #{escape(value)}"
+ when :greater_than
+ sql << "`#{key}` > #{escape(value)}"
+ when :less_than_or_equal_to
+ sql << "`#{key}` <= #{escape(value)}"
+ when :greater_than_or_equal_to
+ sql << "`#{key}` >= #{escape(value)}"
+ end
+ end
+ sql.empty? ? "1=1" : sql.join(joiner)
+ else
+ "`#{key}` = #{escape(value)}"
+ end
+ end.join(joiner)
+ end
+
+ end
+ end
+end
diff --git a/lib/postal/message_db/delivery.rb b/lib/postal/message_db/delivery.rb
new file mode 100644
index 0000000..e970436
--- /dev/null
+++ b/lib/postal/message_db/delivery.rb
@@ -0,0 +1,71 @@
+module Postal
+ module MessageDB
+ class Delivery
+
+ def self.create(message, attributes = {})
+ attributes = message.database.stringify_keys(attributes)
+ attributes = attributes.merge('message_id' => message.id, 'timestamp' => Time.now.to_f)
+ id = message.database.insert('deliveries', attributes)
+ delivery = Delivery.new(message, attributes.merge('id' => id))
+ delivery.update_statistics
+ delivery.send_webhooks
+ delivery
+ end
+
+ def initialize(message, attributes)
+ @message = message
+ @attributes = attributes.stringify_keys
+ end
+
+ def method_missing(name, value = nil, &block)
+ if @attributes.has_key?(name.to_s)
+ @attributes[name.to_s]
+ else
+ nil
+ end
+ end
+
+ def timestamp
+ @timestamp ||= @attributes['timestamp'] ? Time.at(@attributes['timestamp']) : nil
+ end
+
+ def update_statistics
+ if self.status == 'Held'
+ @message.database.statistics.increment_all(self.timestamp, 'held')
+ end
+
+ if self.status == 'Bounced' || self.status == 'HardFail'
+ @message.database.statistics.increment_all(self.timestamp, 'bounces')
+ end
+ end
+
+ def send_webhooks
+ if self.webhook_event
+ WebhookRequest.trigger(@message.database.server_id, self.webhook_event, self.webhook_hash)
+ end
+ end
+
+ def webhook_hash
+ {
+ :message => @message.webhook_hash,
+ :status => self.status,
+ :details => self.details,
+ :output => self.output.to_s.force_encoding('UTF-8').scrub,
+ :sent_with_ssl => self.sent_with_ssl,
+ :timestamp => @attributes['timestamp'],
+ :time => self.time
+ }
+ end
+
+ def webhook_event
+ @webhook_event ||= case self.status
+ when 'Sent' then 'MessageSent'
+ when 'SoftFail' then 'MessageDelayed'
+ when 'HardFail' then 'MessageDeliveryFailed'
+ when 'Held' then 'MessageHeld'
+ end
+ end
+
+ end
+ end
+end
diff --git a/lib/postal/message_db/live_stats.rb b/lib/postal/message_db/live_stats.rb
new file mode 100644
index 0000000..c782730
--- /dev/null
+++ b/lib/postal/message_db/live_stats.rb
@@ -0,0 +1,41 @@
+module Postal
+ module MessageDB
+ class LiveStats
+
+ def initialize(database)
+ @database = database
+ end
+
+ #
+ # Increment the live stats by one for the current minute
+ #
+ def increment(type)
+ time = Time.now.utc
+ type = @database.escape(type.to_s)
+ sql_query = "INSERT INTO `#{@database.database_name}`.`live_stats` (type, minute, timestamp, count)"
+ sql_query << " VALUES (#{type}, #{time.min}, #{time.to_f}, 1)"
+ sql_query << " ON DUPLICATE KEY UPDATE count = if(timestamp < #{time.to_f - 1800}, 1, count + 1), timestamp = #{time.to_f}"
+ @database.query(sql_query)
+ end
+
+ #
+ # Return the total number of messages for the last 60 minutes
+ #
+ def total(minutes, options = {})
+ if minutes > 60
+ raise Postal::Error, "Live stats can only return data for the last 60 minutes."
+ end
+ options[:types] ||= [:incoming, :outgoing]
+ if options[:types].empty?
+ raise Postal::Error, "You must provide at least one type to return"
+ else
+ time = minutes.minutes.ago.beginning_of_minute.utc.to_f
+ types = options[:types].map {|t| "#{@database.escape(t.to_s)}"}.join(', ')
+ result = @database.query("SELECT SUM(count) as count FROM `#{@database.database_name}`.`live_stats` WHERE `type` IN (#{types}) AND timestamp > #{time}").first
+ result['count'] || 0
+ end
+ end
+
+ end
+ end
+end
diff --git a/lib/postal/message_db/load.rb b/lib/postal/message_db/load.rb
new file mode 100644
index 0000000..05fd11a
--- /dev/null
+++ b/lib/postal/message_db/load.rb
@@ -0,0 +1,17 @@
+module Postal
+ module MessageDB
+ class Load
+
+ def initialize(attributes)
+ @ip_address = attributes['ip_address']
+ @user_agent = attributes['user_agent']
+ @timestamp = Time.at(attributes['timestamp'])
+ end
+
+ attr_reader :ip_address
+ attr_reader :user_agent
+ attr_reader :timestamp
+
+ end
+ end
+end
diff --git a/lib/postal/message_db/message.rb b/lib/postal/message_db/message.rb
new file mode 100644
index 0000000..628d613
--- /dev/null
+++ b/lib/postal/message_db/message.rb
@@ -0,0 +1,576 @@
+module Postal
+ module MessageDB
+ class Message
+
+ class NotFound < Postal::Error
+ end
+
+ def self.find_one(database, query)
+ query = {:id => query.to_i} if query.is_a?(Fixnum)
+ if message = database.select('messages', :where => query, :limit => 1).first
+ Message.new(database, message)
+ else
+ raise NotFound, "No message found matching provided query #{query}"
+ end
+ end
+
+ def self.find(database, options = {})
+ if messages = database.select('messages', options)
+ if messages.is_a?(Array)
+ messages.map { |m| Message.new(database, m) }
+ else
+ messages
+ end
+ else
+ []
+ end
+ end
+
+ def self.find_with_pagination(database, page, options = {})
+ messages = database.select_with_pagination('messages', page, options)
+ messages[:records] = messages[:records].map { |m| Message.new(database, m) }
+ messages
+ end
+
+ attr_reader :database
+
+ def initialize(database, attributes)
+ @database = database
+ @attributes = attributes
+ end
+
+ #
+ # Return the server for this message
+ #
+ def server
+ @database.server
+ end
+
+ #
+ # Return the credential for this message
+ #
+ def credential
+ @credential ||= self.credential_id ? Credential.find_by_id(self.credential_id) : nil
+ end
+
+ #
+ # Return the route for this message
+ #
+ def route
+ @route ||= self.route_id ? Route.find_by_id(self.route_id) : nil
+ end
+
+ #
+ # Return the endpoint for this message
+ #
+ def endpoint
+ @endpoint ||= begin
+ if self.endpoint_type && self.endpoint_id
+ self.endpoint_type.constantize.find_by_id(self.endpoint_id)
+ elsif self.route && self.route.mode == 'Endpoint'
+ self.route.endpoint
+ end
+ end
+ end
+
+ #
+ # Return the credential for this message
+ #
+ def domain
+ @domain ||= self.domain_id ? Domain.find_by_id(self.domain_id) : nil
+ end
+
+ #
+ # Copy appropriate attributes from the raw message to the message itself
+ #
+ def copy_attributes_from_raw_message
+ if self.raw_message
+ self.subject = self.headers['subject']&.last
+ self.message_id = self.headers['message-id']&.last
+ if self.message_id
+ self.message_id = self.message_id.gsub(/.*, '').gsub(/>.*/, '').strip
+ end
+ end
+ end
+
+ #
+ # Return the timestamp for this message
+ #
+ def timestamp
+ @timestamp ||= @attributes['timestamp'] ? Time.at(@attributes['timestamp']) : nil
+ end
+
+ #
+ # Return the time that the last delivery was attempted
+ #
+ def last_delivery_attempt
+ @last_delivery_attempt ||= @attributes['last_delivery_attempt'] ? Time.at(@attributes['last_delivery_attempt']) : nil
+ end
+
+ #
+ # Return the hold expiry for this message
+ #
+ def hold_expiry
+ @hold_expiry ||= @attributes['hold_expiry'] ? Time.at(@attributes['hold_expiry']) : nil
+ end
+
+ #
+ # Has this message been read?
+ #
+ def read?
+ !!(loaded || clicked)
+ end
+
+ #
+ # Add a delivery attempt for this message
+ #
+ def create_delivery(status, options = {})
+ delivery = Delivery.create(self, options.merge(:status => status))
+ hold_expiry = status == 'Held' ? 7.days.from_now.to_f : nil
+ self.update(:status => status, :last_delivery_attempt => delivery.timestamp.to_f, :held => status == 'Held' ? 1 : 0, :hold_expiry => hold_expiry)
+ delivery
+ end
+
+ #
+ # Return all deliveries for this object
+ #
+ def deliveries
+ @deliveries ||= begin
+ @database.select('deliveries', :where => {:message_id => self.id}, :order => :timestamp).map do |hash|
+ Delivery.new(self, hash)
+ end
+ end
+ end
+
+ #
+ # Return all the clicks for this object
+ #
+ def clicks
+ @clicks ||= begin
+ clicks = @database.select('clicks', :where => {:message_id => self.id}, :order => :timestamp)
+ if clicks.empty?
+ []
+ else
+ links = @database.select('links', :where => {:id => clicks.map { |c| c['link_id'].to_i }}).group_by { |l| l['id'] }
+ clicks.map do |hash|
+ Click.new(hash, links[hash['link_id']].first)
+ end
+ end
+ end
+ end
+
+ #
+ # Return all the loads for this object
+ #
+ def loads
+ @loads ||= begin
+ loads = @database.select('loads', :where => {:message_id => self.id}, :order => :timestamp)
+ loads.map do |hash|
+ Load.new(hash)
+ end
+ end
+ end
+
+ #
+ # Return all activity entries
+ #
+ def activity_entries
+ @activity_entries ||= (deliveries + clicks + loads).sort_by(&:timestamp)
+ end
+
+ #
+ # Provide access to set and get acceptable attributes
+ #
+ def method_missing(name, value = nil, &block)
+ if @attributes.has_key?(name.to_s)
+ @attributes[name.to_s]
+ elsif name.to_s =~ /\=\z/
+ @attributes[name.to_s.gsub('=', '').to_s] = value
+ else
+ nil
+ end
+ end
+
+ #
+ # Has this message been persisted to the database yet?
+ #
+ def persisted?
+ !@attributes['id'].nil?
+ end
+
+ #
+ # Save this message
+ #
+ def save
+ save_raw_message
+ persisted? ? _update : _create
+ self
+ end
+
+ #
+ # Update this message
+ #
+ def update(attributes_to_change)
+ @attributes = @attributes.merge(database.stringify_keys(attributes_to_change))
+ if persisted?
+ @database.update('messages', attributes_to_change, :where => {:id => self.id})
+ else
+ _create
+ end
+ end
+
+ #
+ # Delete the message from the database
+ #
+ def delete
+ if persisted?
+ @database.delete('messages', :where => {:id => self.id})
+ end
+ end
+
+ #
+ # Return the headers
+ #
+ def raw_headers
+ if self.raw_table
+ @raw_headers ||= @database.select(self.raw_table, :where => {:id => self.raw_headers_id}).first&.send(:[], 'data') || ""
+ else
+ ""
+ end
+ end
+
+ #
+ # Return the full raw message body for this message.
+ #
+ def raw_body
+ if self.raw_table
+ @raw ||= @database.select(self.raw_table, :where => {:id => self.raw_body_id}).first&.send(:[], 'data') || ""
+ else
+ ""
+ end
+ end
+
+ #
+ # Return the full raw message for this message
+ #
+ def raw_message
+ @raw_message ||= "#{raw_headers}\r\n\r\n#{raw_body}"
+ end
+
+ #
+ # Set the raw message ready for saving later
+ #
+ def raw_message=(raw)
+ @pending_raw_message = raw.force_encoding('BINARY')
+ end
+
+ #
+ # Save the raw message to the database as appropriate
+ #
+ def save_raw_message
+ if @pending_raw_message
+ self.size = @pending_raw_message.bytesize
+ date = Date.today
+ table_name, headers_id, body_id = @database.insert_raw_message(@pending_raw_message, date)
+ self.raw_table = table_name
+ self.raw_headers_id = headers_id
+ self.raw_body_id = body_id
+ @raw = nil
+ @raw_headers = nil
+ @headers = nil
+ @mail = nil
+ @pending_raw_message = nil
+ copy_attributes_from_raw_message
+ @database.query("UPDATE `#{@database.database_name}`.`raw_message_sizes` SET size = size + #{self.size} WHERE table_name = '#{table_name}'")
+ end
+ end
+
+ #
+ # Is there a raw message?
+ #
+ def raw_message?
+ !!self.raw_table
+ end
+
+ #
+ # Return the plain body for this message
+ #
+ def plain_body
+ mail&.plain_body
+ end
+
+ #
+ # Return the HTML body for this message
+ #
+ def html_body
+ mail&.html_body
+ end
+
+ #
+ # Return the HTML body with any tracking links
+ #
+ def html_body_without_tracking_image
+ html_body.gsub(/\/, '')
+ end
+
+ #
+ # Return all attachments for this message
+ #
+ def attachments
+ mail&.attachments || []
+ end
+
+ #
+ # Return the headers for this message
+ #
+ def headers
+ @headers ||= begin
+ mail = Mail.new(self.raw_headers)
+ mail.header.fields.each_with_object({}) do |field, hash|
+ hash[field.name.downcase] ||= []
+ hash[field.name.downcase] << field.decoded
+ end
+ end
+ end
+
+ #
+ # Return the recipient domain for this message
+ #
+ def recipient_domain
+ self.rcpt_to ? self.rcpt_to.split('@').last : nil
+ end
+
+ #
+ # Create a new item in the message queue for this message
+ #
+ def add_to_message_queue(options = {})
+ QueuedMessage.create!(:message => self, :server_id => @database.server_id, :batch_key => self.batch_key, :domain => self.recipient_domain, :route_id => self.route_id, :manual => options[:manual]).id
+ end
+
+ #
+ # Return a suitable batch key for this message
+ #
+ def batch_key
+ case self.scope
+ when 'outgoing'
+ key = "outgoing-"
+ key += self.recipient_domain.to_s
+ when 'incoming'
+ key = "incoming-"
+ key += "rt:#{self.route_id}-ep:#{self.endpoint_id}-#{self.endpoint_type}"
+ else
+ key = nil
+ end
+ key
+ end
+
+ #
+ # Return the queued message
+ #
+ def queued_message
+ @queued_message ||= self.id ? QueuedMessage.where(:message_id => self.id, :server_id => @database.server_id).first : nil
+ end
+
+ #
+ # Return the spam status
+ #
+ def spam_status
+ return 'NotChecked' unless inspected == 1
+ spam == 1 ? 'Spam' : 'NotSpam'
+ end
+
+ #
+ # Has this message been held?
+ #
+ def held?
+ status == 'Held'
+ end
+
+ #
+ # Does this message have our DKIM header yet?
+ #
+ def has_outgoing_headers?
+ !!(raw_headers =~ /^X\-Postal\-MsgID\:/i)
+ end
+
+ #
+ # Add dkim header
+ #
+ def add_outgoing_headers
+ headers = []
+ if self.domain
+ dkim = Postal::DKIMHeader.new(self.domain, self.raw_message)
+ headers << dkim.dkim_header
+ end
+ headers << "X-Postal-MsgID: #{self.token}"
+ append_headers(*headers)
+ end
+
+ #
+ # Append a header to the existing headers
+ #
+ def append_headers(*headers)
+ new_headers = headers.join("\r\n")
+ new_headers = "#{new_headers}\r\n#{self.raw_headers}"
+ @database.update(self.raw_table, {:data => new_headers}, :where => {:id => self.raw_headers_id})
+ @raw_headers = new_headers
+ @raw_message = nil
+ @headers = nil
+ end
+
+ #
+ # Return a suitable
+ #
+ def webhook_hash
+ @webhook_hash ||= {
+ :id => self.id,
+ :token => self.token,
+ :direction => self.scope,
+ :message_id => self.message_id,
+ :to => self.rcpt_to,
+ :from => self.mail_from,
+ :subject => self.subject,
+ :timestamp => self.timestamp.to_f,
+ :spam_status => self.spam_status,
+ :tag => self.tag
+ }
+ end
+
+ #
+ # Mark this message as bounced
+ #
+ def bounce!(bounce_message)
+ create_delivery('Bounced', :details => "We've received a bounce message for this e-mail. See for details.")
+ SendWebhookJob.queue(:main, :server_id => self.database.server_id, :event => "MessageBounced", :payload => {:_original_message => self.id, :_bounce => bounce_message.id})
+ end
+
+ #
+ # Should bounces be sent for this message?
+ #
+ def send_bounces?
+ self.bounce != 1 && self.mail_from.present?
+ end
+
+ #
+ # Add a load for this message
+ #
+ def create_load(request)
+ update('loaded' => Time.now.to_f) if loaded.nil?
+ database.insert(:loads, {:message_id => self.id, :ip_address => request.ip, :user_agent => request.user_agent, :timestamp => Time.now.to_f})
+ SendWebhookJob.queue(:main, :server_id => self.database.server_id, :event => 'MessageLoaded', :payload => {:_message => self.id, :ip_address => request.ip, :user_agent => request.user_agent})
+ end
+
+ #
+ # Create a new link
+ #
+ def create_link(url)
+ hash = Digest::SHA1.hexdigest(url.to_s)
+ token = Nifty::Utils::RandomString.generate(:length => 8)
+ database.insert(:links, {:message_id => self.id, :hash => hash, :url => url, :timestamp => Time.now.to_f, :token => token})
+ token
+ end
+
+ #
+ # Return a message object that this message is a reply to
+ #
+ def original_messages
+ return nil unless self.bounce == 1
+ other_message_ids = raw_message.scan(/\X\-Postal\-MsgID\:\s*([a-z0-9]+)/i).flatten
+ if other_message_ids.empty?
+ []
+ else
+ database.messages(:where => {:token => other_message_ids})
+ end
+ end
+
+ #
+ # Was thsi message sent to a return path?
+ #
+ def rcpt_to_return_path?
+ !!(rcpt_to =~ /\@#{Regexp.escape(Postal.config.dns.custom_return_path_prefix)}\./)
+ end
+
+ #
+ # Inspect this message
+ #
+ def inspect_message
+ if result = Espect.inspect(self.raw_message, self.scope&.to_sym)
+ # Update the messages table with the results of our inspection
+ update(:inspected => 1, :spam_score => result.spam_score, :threat => result.threat?, :threat_details => result.threat_message)
+ # Add any spam details into the spam checks database
+ self.database.insert_multi(:spam_checks, [:message_id, :code, :score, :description], result.spam_details.map { |d| [self.id, d['code'], d['score'], d['description']]})
+ # Return the espect result
+ result
+ end
+ end
+
+ #
+ # Return all spam checks for this message
+ #
+ def spam_checks
+ @spam_checks ||= self.database.select(:spam_checks, :where => {:message_id => self.id})
+ end
+
+ #
+ # Cancel the hold on this message
+ #
+ def cancel_hold
+ if self.status == 'Held'
+ create_delivery('HoldCancelled', :details => "The hold on this message has been removed without action.")
+ end
+ end
+
+ #
+ # Parse the contents of this message
+ #
+ def parse_content
+ parse_result = Postal::MessageParser.new(self)
+ if parse_result.actioned?
+ # Somethign was changed, update the raw message
+ @database.update(self.raw_table, {:data => parse_result.new_body}, :where => {:id => self.raw_body_id})
+ @raw = parse_result.new_body
+ @raw_message = nil
+ end
+ update('parsed' => 1, 'tracked_links' => parse_result.tracked_links, 'tracked_images' => parse_result.tracked_images)
+ end
+
+ #
+ # Has this message been parsed?
+ #
+ def parsed?
+ self.parsed == 1
+ end
+
+ #
+ # Should this message be parsed?
+ #
+ def should_parse?
+ parsed? == false && headers['x-amp'] != 'skip'
+ end
+
+ private
+
+ def _update
+ @database.update('messages', @attributes.reject {|k,v| k == :id }, :where => {:id => @attributes['id']})
+ end
+
+ def _create
+ self.timestamp = Time.now.to_f if self.timestamp.blank?
+ self.status = 'Pending' if self.status.blank?
+ self.token = Nifty::Utils::RandomString.generate(:length => 12) if self.token.blank?
+ last_id = @database.insert('messages', @attributes.reject {|k,v| k == :id })
+ @attributes['id'] = last_id
+ @database.statistics.increment_all(self.timestamp, self.scope)
+ Statistic.global.increment!(:total_messages)
+ Statistic.global.increment!("total_#{self.scope}".to_sym)
+ add_to_message_queue
+ end
+
+ def mail
+ # This version of mail is only used for accessing the bodies.
+ @mail ||= raw_message? ? Mail.new(raw_message) : nil
+ end
+
+ end
+ end
+end
diff --git a/lib/postal/message_db/migration.rb b/lib/postal/message_db/migration.rb
new file mode 100644
index 0000000..fc1948f
--- /dev/null
+++ b/lib/postal/message_db/migration.rb
@@ -0,0 +1,35 @@
+module Postal
+ module MessageDB
+ class Migration
+
+ def initialize(database)
+ @database = database
+ end
+
+ def up
+ end
+
+ def self.run(database, start_from = database.schema_version)
+ files = Dir[Rails.root.join('lib', 'postal', 'message_db', 'migrations', '*.rb')]
+ files = files.map { |f| id, name = f.split('/').last.split('_', 2); [id.to_i, name] }.sort_by(&:first)
+ latest_version = files.last.first
+ if latest_version > start_from
+ puts "\e[32mMigrating #{database.database_name} from version #{start_from} => #{files.last.first}\e[0m"
+ else
+ puts "Nothing to do."
+ end
+ files.each do |version, file|
+ klass_name = file.gsub(/\.rb\z/, '').camelize
+ next if start_from >= version
+ puts "\e[45m++ Migrating #{klass_name} (#{version})\e[0m"
+ require "postal/message_db/migrations/#{version.to_s.rjust(2, '0')}_#{file}"
+ klass = Postal::MessageDB::Migrations.const_get(klass_name)
+ instance = klass.new(database)
+ instance.up
+ database.insert(:migrations, :version => version)
+ end
+ end
+
+ end
+ end
+end
diff --git a/lib/postal/message_db/migrations/01_create_migrations.rb b/lib/postal/message_db/migrations/01_create_migrations.rb
new file mode 100644
index 0000000..fbbb2ce
--- /dev/null
+++ b/lib/postal/message_db/migrations/01_create_migrations.rb
@@ -0,0 +1,16 @@
+module Postal
+ module MessageDB
+ module Migrations
+ class CreateMigrations < Postal::MessageDB::Migration
+ def up
+ @database.provisioner.create_table(:migrations,
+ :columns => {
+ :version => 'int(11) NOT NULL',
+ },
+ :primary_key => '`version`'
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/postal/message_db/migrations/02_create_messages.rb b/lib/postal/message_db/migrations/02_create_messages.rb
new file mode 100644
index 0000000..c8f1cb2
--- /dev/null
+++ b/lib/postal/message_db/migrations/02_create_messages.rb
@@ -0,0 +1,57 @@
+module Postal
+ module MessageDB
+ module Migrations
+ class CreateMessages < Postal::MessageDB::Migration
+ def up
+ @database.provisioner.create_table(:messages,
+ :columns => {
+ :id => 'int(11) NOT NULL AUTO_INCREMENT',
+ :token => 'varchar(255) DEFAULT NULL',
+ :scope => 'varchar(10) DEFAULT NULL',
+ :rcpt_to => 'varchar(255) DEFAULT NULL',
+ :mail_from => 'varchar(255) DEFAULT NULL',
+ :subject => 'varchar(255) DEFAULT NULL',
+ :message_id => 'varchar(255) DEFAULT NULL',
+ :timestamp => 'decimal(18,6) DEFAULT NULL',
+ :route_id => 'int(11) DEFAULT NULL',
+ :domain_id => 'int(11) DEFAULT NULL',
+ :credential_id => 'int(11) DEFAULT NULL',
+ :status => 'varchar(255) DEFAULT NULL',
+ :held => 'tinyint(1) DEFAULT 0',
+ :size => 'varchar(255) DEFAULT NULL',
+ :last_delivery_attempt => 'decimal(18,6) DEFAULT NULL',
+ :raw_table => 'varchar(255) DEFAULT NULL',
+ :raw_body_id => 'int(11) DEFAULT NULL',
+ :raw_headers_id => 'int(11) DEFAULT NULL',
+ :inspected => 'tinyint(1) DEFAULT 0',
+ :spam => 'tinyint(1) DEFAULT 0',
+ :spam_score => 'decimal(8,2) DEFAULT 0',
+ :threat => 'tinyint(1) DEFAULT 0',
+ :threat_details => 'varchar(255) DEFAULT NULL',
+ :bounce => 'tinyint(1) DEFAULT 0',
+ :bounce_for_id => 'int(11) DEFAULT 0',
+ :tag => 'varchar(255) DEFAULT NULL',
+ :loaded => 'decimal(18,6) DEFAULT NULL',
+ :clicked => 'decimal(18,6) DEFAULT NULL',
+ :received_with_ssl => 'tinyint(1) DEFAULT NULL',
+ },
+ :indexes => {
+ :on_message_id => '`message_id`(8)',
+ :on_token => '`token`(6)',
+ :on_bounce_for_id => '`bounce_for_id`',
+ :on_held => '`held`',
+ :on_scope_and_status => '`scope`(1), `spam`, `status`(6), `timestamp`',
+ :on_scope_and_tag => '`scope`(1), `spam`, `tag`(8), `timestamp`',
+ :on_scope_and_spam => '`scope`(1), `spam`, `timestamp`',
+ :on_scope_and_thr_status => '`scope`(1), `threat`, `status`(6), `timestamp`',
+ :on_scope_and_threat => '`scope`(1), `threat`, `timestamp`',
+ :on_rcpt_to => '`rcpt_to`(12), `timestamp`',
+ :on_mail_from => '`mail_from`(12), `timestamp`',
+ :on_raw_table => '`raw_table`(14)',
+ }
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/postal/message_db/migrations/03_create_deliveries.rb b/lib/postal/message_db/migrations/03_create_deliveries.rb
new file mode 100644
index 0000000..fee20be
--- /dev/null
+++ b/lib/postal/message_db/migrations/03_create_deliveries.rb
@@ -0,0 +1,26 @@
+module Postal
+ module MessageDB
+ module Migrations
+ class CreateDeliveries < Postal::MessageDB::Migration
+ def up
+ @database.provisioner.create_table(:deliveries,
+ :columns => {
+ :id => 'int(11) NOT NULL AUTO_INCREMENT',
+ :message_id => 'int(11) DEFAULT NULL',
+ :status => 'varchar(255) DEFAULT NULL',
+ :code => 'int(11) DEFAULT NULL',
+ :output => 'varchar(512) DEFAULT NULL',
+ :details => 'varchar(512) DEFAULT NULL',
+ :sent_with_ssl => 'tinyint(1) DEFAULT 0',
+ :log_id => 'varchar(100) DEFAULT NULL',
+ :timestamp => 'decimal(18,6) DEFAULT NULL'
+ },
+ :indexes => {
+ :on_message_id => '`message_id`'
+ }
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/postal/message_db/migrations/04_create_live_stats.rb b/lib/postal/message_db/migrations/04_create_live_stats.rb
new file mode 100644
index 0000000..dc26ef7
--- /dev/null
+++ b/lib/postal/message_db/migrations/04_create_live_stats.rb
@@ -0,0 +1,19 @@
+module Postal
+ module MessageDB
+ module Migrations
+ class CreateLiveStats < Postal::MessageDB::Migration
+ def up
+ @database.provisioner.create_table(:live_stats,
+ :columns => {
+ :type => 'varchar(20) NOT NULL',
+ :minute => 'int(11) NOT NULL',
+ :count => 'int(11) DEFAULT NULL',
+ :timestamp => 'decimal(18,6) DEFAULT NULL',
+ },
+ :primary_key => '`minute`, `type`(8)'
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/postal/message_db/migrations/05_create_raw_message_sizes.rb b/lib/postal/message_db/migrations/05_create_raw_message_sizes.rb
new file mode 100644
index 0000000..4483c02
--- /dev/null
+++ b/lib/postal/message_db/migrations/05_create_raw_message_sizes.rb
@@ -0,0 +1,20 @@
+module Postal
+ module MessageDB
+ module Migrations
+ class CreateRawMessageSizes < Postal::MessageDB::Migration
+ def up
+ @database.provisioner.create_table(:raw_message_sizes,
+ :columns => {
+ :id => 'int(11) NOT NULL AUTO_INCREMENT',
+ :table_name => 'varchar(255) DEFAULT NULL',
+ :size => 'bigint DEFAULT NULL'
+ },
+ :indexes => {
+ :on_table_name => '`table_name`(14)'
+ }
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/postal/message_db/migrations/06_create_clicks.rb b/lib/postal/message_db/migrations/06_create_clicks.rb
new file mode 100644
index 0000000..252073a
--- /dev/null
+++ b/lib/postal/message_db/migrations/06_create_clicks.rb
@@ -0,0 +1,26 @@
+module Postal
+ module MessageDB
+ module Migrations
+ class CreateClicks < Postal::MessageDB::Migration
+ def up
+ @database.provisioner.create_table(:clicks,
+ :columns => {
+ :id => 'int(11) NOT NULL AUTO_INCREMENT',
+ :message_id => 'int(11) DEFAULT NULL',
+ :link_id => 'int(11) DEFAULT NULL',
+ :ip_address => 'varchar(255) DEFAULT NULL',
+ :country => 'varchar(255) DEFAULT NULL',
+ :city => 'varchar(255) DEFAULT NULL',
+ :user_agent => 'varchar(255) DEFAULT NULL',
+ :timestamp => 'decimal(18,6) DEFAULT NULL'
+ },
+ :indexes => {
+ :on_message_id => '`message_id`',
+ :on_link_id => '`link_id`'
+ }
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/postal/message_db/migrations/07_create_loads.rb b/lib/postal/message_db/migrations/07_create_loads.rb
new file mode 100644
index 0000000..82449be
--- /dev/null
+++ b/lib/postal/message_db/migrations/07_create_loads.rb
@@ -0,0 +1,24 @@
+module Postal
+ module MessageDB
+ module Migrations
+ class CreateLoads < Postal::MessageDB::Migration
+ def up
+ @database.provisioner.create_table(:loads,
+ :columns => {
+ :id => 'int(11) NOT NULL AUTO_INCREMENT',
+ :message_id => 'int(11) DEFAULT NULL',
+ :ip_address => 'varchar(255) DEFAULT NULL',
+ :country => 'varchar(255) DEFAULT NULL',
+ :city => 'varchar(255) DEFAULT NULL',
+ :user_agent => 'varchar(255) DEFAULT NULL',
+ :timestamp => 'decimal(18,6) DEFAULT NULL'
+ },
+ :indexes => {
+ :on_message_id => '`message_id`'
+ }
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/postal/message_db/migrations/08_create_stats.rb b/lib/postal/message_db/migrations/08_create_stats.rb
new file mode 100644
index 0000000..609ae9d
--- /dev/null
+++ b/lib/postal/message_db/migrations/08_create_stats.rb
@@ -0,0 +1,26 @@
+module Postal
+ module MessageDB
+ module Migrations
+ class CreateStats < Postal::MessageDB::Migration
+ def up
+ [:hourly, :daily, :monthly, :yearly].each do |table_name|
+ @database.provisioner.create_table("stats_#{table_name}",
+ :columns => {
+ :id => 'int(11) NOT NULL AUTO_INCREMENT',
+ :time => 'int(11) DEFAULT NULL',
+ :incoming => 'bigint DEFAULT NULL',
+ :outgoing => 'bigint DEFAULT NULL',
+ :spam => 'bigint DEFAULT NULL',
+ :bounces => 'bigint DEFAULT NULL',
+ :held => 'bigint DEFAULT NULL',
+ },
+ :unique_indexes => {
+ :on_time => '`time`'
+ }
+ )
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/postal/message_db/migrations/09_create_links.rb b/lib/postal/message_db/migrations/09_create_links.rb
new file mode 100644
index 0000000..b5c5749
--- /dev/null
+++ b/lib/postal/message_db/migrations/09_create_links.rb
@@ -0,0 +1,24 @@
+module Postal
+ module MessageDB
+ module Migrations
+ class CreateLinks < Postal::MessageDB::Migration
+ def up
+ @database.provisioner.create_table(:links,
+ :columns => {
+ :id => 'int(11) NOT NULL AUTO_INCREMENT',
+ :message_id => 'int(11) DEFAULT NULL',
+ :token => 'varchar(255) DEFAULT NULL',
+ :hash => 'varchar(255) DEFAULT NULL',
+ :url => 'varchar(255) DEFAULT NULL',
+ :timestamp => 'decimal(18,6) DEFAULT NULL'
+ },
+ :indexes => {
+ :on_message_id => '`message_id`',
+ :on_token => '`token`(8)',
+ }
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/postal/message_db/migrations/10_create_spam_checks.rb b/lib/postal/message_db/migrations/10_create_spam_checks.rb
new file mode 100644
index 0000000..f3602f5
--- /dev/null
+++ b/lib/postal/message_db/migrations/10_create_spam_checks.rb
@@ -0,0 +1,23 @@
+module Postal
+ module MessageDB
+ module Migrations
+ class CreateSpamChecks < Postal::MessageDB::Migration
+ def up
+ @database.provisioner.create_table(:spam_checks,
+ :columns => {
+ :id => 'int(11) NOT NULL AUTO_INCREMENT',
+ :message_id => 'int(11) DEFAULT NULL',
+ :score => 'decimal(8,2) DEFAULT NULL',
+ :code => 'varchar(255) DEFAULT NULL',
+ :description => 'varchar(255) DEFAULT NULL'
+ },
+ :indexes => {
+ :on_message_id => '`message_id`',
+ :on_code => '`code`(8)',
+ }
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/postal/message_db/migrations/11_add_time_to_deliveries.rb b/lib/postal/message_db/migrations/11_add_time_to_deliveries.rb
new file mode 100644
index 0000000..68199da
--- /dev/null
+++ b/lib/postal/message_db/migrations/11_add_time_to_deliveries.rb
@@ -0,0 +1,11 @@
+module Postal
+ module MessageDB
+ module Migrations
+ class AddTimeToDeliveries < Postal::MessageDB::Migration
+ def up
+ @database.query("ALTER TABLE `#{@database.database_name}`.`deliveries` ADD COLUMN `time` decimal(8,2)")
+ end
+ end
+ end
+ end
+end
diff --git a/lib/postal/message_db/migrations/12_add_hold_expiry.rb b/lib/postal/message_db/migrations/12_add_hold_expiry.rb
new file mode 100644
index 0000000..05063ba
--- /dev/null
+++ b/lib/postal/message_db/migrations/12_add_hold_expiry.rb
@@ -0,0 +1,11 @@
+module Postal
+ module MessageDB
+ module Migrations
+ class AddHoldExpiry < Postal::MessageDB::Migration
+ def up
+ @database.query("ALTER TABLE `#{@database.database_name}`.`messages` ADD COLUMN `hold_expiry` decimal(18,6)")
+ end
+ end
+ end
+ end
+end
diff --git a/lib/postal/message_db/migrations/13_add_index_to_message_status.rb b/lib/postal/message_db/migrations/13_add_index_to_message_status.rb
new file mode 100644
index 0000000..0cdacab
--- /dev/null
+++ b/lib/postal/message_db/migrations/13_add_index_to_message_status.rb
@@ -0,0 +1,11 @@
+module Postal
+ module MessageDB
+ module Migrations
+ class AddIndexToMessageStatus < Postal::MessageDB::Migration
+ def up
+ @database.query("ALTER TABLE `#{@database.database_name}`.`messages` ADD INDEX `on_status` (`status`(8)) USING BTREE")
+ end
+ end
+ end
+ end
+end
diff --git a/lib/postal/message_db/migrations/14_create_suppressions.rb b/lib/postal/message_db/migrations/14_create_suppressions.rb
new file mode 100644
index 0000000..9b0ec21
--- /dev/null
+++ b/lib/postal/message_db/migrations/14_create_suppressions.rb
@@ -0,0 +1,24 @@
+module Postal
+ module MessageDB
+ module Migrations
+ class CreateSuppressions < Postal::MessageDB::Migration
+ def up
+ @database.provisioner.create_table(:suppressions,
+ :columns => {
+ :id => 'int(11) NOT NULL AUTO_INCREMENT',
+ :type => 'varchar(255) DEFAULT NULL',
+ :address => 'varchar(255) DEFAULT NULL',
+ :reason => 'varchar(255) DEFAULT NULL',
+ :timestamp => 'decimal(18,6) DEFAULT NULL',
+ :keep_until => 'decimal(18,6) DEFAULT NULL',
+ },
+ :indexes => {
+ :on_address => '`address`(6)',
+ :on_keep_until => '`keep_until`',
+ }
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/postal/message_db/migrations/15_create_webhook_requests.rb b/lib/postal/message_db/migrations/15_create_webhook_requests.rb
new file mode 100644
index 0000000..03987df
--- /dev/null
+++ b/lib/postal/message_db/migrations/15_create_webhook_requests.rb
@@ -0,0 +1,28 @@
+module Postal
+ module MessageDB
+ module Migrations
+ class CreateWebhookRequests < Postal::MessageDB::Migration
+ def up
+ @database.provisioner.create_table(:webhook_requests,
+ :columns => {
+ :id => 'int(11) NOT NULL AUTO_INCREMENT',
+ :uuid => 'varchar(255) DEFAULT NULL',
+ :event => 'varchar(255) DEFAULT NULL',
+ :attempt => 'int(11) DEFAULT NULL',
+ :timestamp => 'decimal(18,6) DEFAULT NULL',
+ :status_code => 'int(1) DEFAULT NULL',
+ :body => 'text DEFAULT NULL',
+ :payload => 'text DEFAULT NULL',
+ :will_retry => 'tinyint DEFAULT NULL'
+ },
+ :indexes => {
+ :on_uuid => '`uuid`(8)',
+ :on_event => '`event`(8)',
+ :on_timestamp => '`timestamp`',
+ }
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/postal/message_db/migrations/16_add_url_and_hook_to_webhooks.rb b/lib/postal/message_db/migrations/16_add_url_and_hook_to_webhooks.rb
new file mode 100644
index 0000000..5d03f03
--- /dev/null
+++ b/lib/postal/message_db/migrations/16_add_url_and_hook_to_webhooks.rb
@@ -0,0 +1,13 @@
+module Postal
+ module MessageDB
+ module Migrations
+ class AddUrlAndHookToWebhooks < Postal::MessageDB::Migration
+ def up
+ @database.query("ALTER TABLE `#{@database.database_name}`.`webhook_requests` ADD COLUMN `url` varchar(255)")
+ @database.query("ALTER TABLE `#{@database.database_name}`.`webhook_requests` ADD COLUMN `webhook_id` int(11)")
+ @database.query("ALTER TABLE `#{@database.database_name}`.`webhook_requests` ADD INDEX `on_webhook_id` (`webhook_id`) USING BTREE")
+ end
+ end
+ end
+ end
+end
diff --git a/lib/postal/message_db/migrations/17_add_replaced_link_count_to_messages.rb b/lib/postal/message_db/migrations/17_add_replaced_link_count_to_messages.rb
new file mode 100644
index 0000000..8496132
--- /dev/null
+++ b/lib/postal/message_db/migrations/17_add_replaced_link_count_to_messages.rb
@@ -0,0 +1,13 @@
+module Postal
+ module MessageDB
+ module Migrations
+ class AddReplacedLinkCountToMessages < Postal::MessageDB::Migration
+ def up
+ @database.query("ALTER TABLE `#{@database.database_name}`.`messages` ADD COLUMN `tracked_links` int(11) DEFAULT 0")
+ @database.query("ALTER TABLE `#{@database.database_name}`.`messages` ADD COLUMN `tracked_images` int(11) DEFAULT 0")
+ @database.query("ALTER TABLE `#{@database.database_name}`.`messages` ADD COLUMN `parsed` tinyint DEFAULT 0")
+ end
+ end
+ end
+ end
+end
diff --git a/lib/postal/message_db/migrations/18_add_endpoints_to_messages.rb b/lib/postal/message_db/migrations/18_add_endpoints_to_messages.rb
new file mode 100644
index 0000000..b693383
--- /dev/null
+++ b/lib/postal/message_db/migrations/18_add_endpoints_to_messages.rb
@@ -0,0 +1,11 @@
+module Postal
+ module MessageDB
+ module Migrations
+ class AddEndpointsToMessages < Postal::MessageDB::Migration
+ def up
+ @database.query("ALTER TABLE `#{@database.database_name}`.`messages` ADD COLUMN `endpoint_id` int(11), ADD COLUMN `endpoint_type` varchar(255)")
+ end
+ end
+ end
+ end
+end
diff --git a/lib/postal/message_db/mysql.rb b/lib/postal/message_db/mysql.rb
new file mode 100644
index 0000000..3792437
--- /dev/null
+++ b/lib/postal/message_db/mysql.rb
@@ -0,0 +1,35 @@
+module Postal
+ module MessageDB
+ module MySQL
+
+ # This exists here because it needs to be required when the application loads
+ # so that it isn't unloaded in development. If it was unloaded in development,
+ # it would be undesirable as we'd just end up with lots of connections.
+
+ def self.new_client
+ Mysql2::Client.new(:host => Postal.config.message_db.host, :username => Postal.config.message_db.username, :password => Postal.config.message_db.password, :port => Postal.config.message_db.port, :reconnect => true)
+ end
+
+ @free_clients = []
+
+ def self.client(&block)
+ client = @free_clients.shift || self.new_client
+ return_value = nil
+ tries = 2
+ begin
+ return_value = block.call(client)
+ rescue Mysql2::Error => e
+ if e.message =~ /(lost connection|gone away)/i && (tries -= 1) > 0
+ retry
+ else
+ raise
+ end
+ ensure
+ @free_clients << client
+ end
+ return_value
+ end
+
+ end
+ end
+end
diff --git a/lib/postal/message_db/provisioner.rb b/lib/postal/message_db/provisioner.rb
new file mode 100644
index 0000000..a13208b
--- /dev/null
+++ b/lib/postal/message_db/provisioner.rb
@@ -0,0 +1,183 @@
+module Postal
+ module MessageDB
+ class Provisioner
+
+ def initialize(database)
+ @database = database
+ end
+
+ #
+ # Provisions a new database
+ #
+ def provision
+ drop
+ create
+ migrate
+ end
+
+ #
+ # Migrate this database
+ #
+ def migrate(start_from = @database.schema_version)
+ Postal::MessageDB::Migration.run(@database, start_from)
+ end
+
+ #
+ # Does a database already exist?
+ #
+ def exists?
+ !!@database.query("SELECT schema_name FROM `information_schema`.`schemata` WHERE schema_name = '#{@database.database_name}'").first
+ end
+
+ #
+ # Creates a new empty database
+ #
+ def create
+ @database.query("CREATE DATABASE `#{@database.database_name}` CHARSET utf8 COLLATE UTF8_UNICODE_CI;")
+ true
+ rescue Mysql2::Error => e
+ e.message =~ /database exists/ ? false : raise
+ end
+
+ #
+ # Drops the whole message database
+ #
+ def drop
+ @database.query("DROP DATABASE `#{@database.database_name}`;")
+ true
+ rescue Mysql2::Error => e
+ e.message =~ /doesn\'t exist/ ? false : raise
+ end
+
+ #
+ # Create a new table
+ #
+ def create_table(table_name, options)
+ @database.query(create_table_query(table_name, options))
+ end
+
+ #
+ # Drop a table
+ #
+ def drop_table(table_name)
+ @database.query("DROP TABLE `#{@database.database_name}`.`#{table_name}`")
+ end
+
+ #
+ # Creates a new empty raw message table for the given date. Returns nothing.
+ #
+ def create_raw_table(table)
+ begin
+ @database.query(create_table_query(table,:columns => {
+ :id => 'int(11) NOT NULL AUTO_INCREMENT',
+ :data => 'mediumblob DEFAULT NULL',
+ :next => 'int(11) DEFAULT NULL'
+ }
+ ))
+ @database.query("INSERT INTO `#{@database.database_name}`.`raw_message_sizes` (table_name, size) VALUES ('#{table}', 0)")
+ rescue Mysql2::Error => e
+ # Don't worry if the table already exists, another thread has already run this code.
+ raise unless e.message =~ /already exists/
+ end
+ end
+
+ #
+ # Return a list of raw message tables that are older than the given date
+ #
+ def raw_tables(max_age = 30)
+ earliest_date = max_age ? Date.today - max_age : nil
+ [].tap do |tables|
+ @database.query("SHOW TABLES FROM `#{@database.database_name}` LIKE 'raw-%'").each do |tbl|
+ tbl_name = tbl.to_a.first.last
+ date = Date.parse(tbl_name.gsub(/\Araw\-/, ''))
+ if earliest_date.nil? || date < earliest_date
+ tables << tbl_name
+ end
+ end
+ end.sort
+ end
+
+ #
+ # Tidy all messages
+ #
+ def remove_raw_tables_older_than(max_age = 30)
+ raw_tables(max_age).each do |table|
+ remove_raw_table(table)
+ end
+ end
+
+ #
+ # Remove a raw message table
+ #
+ def remove_raw_table(table)
+ @database.query("UPDATE `#{@database.database_name}`.`messages` SET raw_table = NULL, raw_headers_id = NULL, raw_body_id = NULL, size = NULL WHERE raw_table = '#{table}'")
+ @database.query("DELETE FROM `#{@database.database_name}`.`raw_message_sizes` WHERE table_name = '#{table}'")
+ drop_table(table)
+ end
+
+ #
+ # Remove messages from the messages table that are too old to retain
+ #
+ def remove_messages(max_age = 60)
+ time = (Date.today - max_age.days).to_time.end_of_day
+ if newest_message_to_remove = @database.select(:messages, :where => {:timestamp => {:less_than_or_equal_to => time.to_f}}, :limit => 1, :order => :id, :direction => 'DESC', :fields => [:id]).first
+ id = newest_message_to_remove['id']
+ @database.query("DELETE FROM `#{@database.database_name}`.`clicks` WHERE `message_id` <= #{id}")
+ @database.query("DELETE FROM `#{@database.database_name}`.`loads` WHERE `message_id` <= #{id}")
+ @database.query("DELETE FROM `#{@database.database_name}`.`deliveries` WHERE `message_id` <= #{id}")
+ @database.query("DELETE FROM `#{@database.database_name}`.`spam_checks` WHERE `message_id` <= #{id}")
+ @database.query("DELETE FROM `#{@database.database_name}`.`messages` WHERE `id` <= #{id}")
+ end
+ end
+
+ #
+ # Remove raw message tables in order order until size is under the given size (given in MB)
+ #
+ def remove_raw_tables_until_less_than_size(size)
+ tables = self.raw_tables(nil)
+ tables_removed = []
+ until @database.total_size <= size
+ table = tables.shift
+ tables_removed << table
+ remove_raw_table(table)
+ end
+ tables_removed
+ end
+
+ private
+
+ #
+ # Build a query to load a table
+ #
+ def create_table_query(table_name, options)
+ String.new.tap do |s|
+ s << "CREATE TABLE `#{@database.database_name}`.`#{table_name}` ("
+ s << options[:columns].map do |column_name, column_options|
+ "`#{column_name}` #{column_options}"
+ end.join(', ')
+ if options[:indexes]
+ s << ", "
+ s << options[:indexes].map do |index_name, index_options|
+ "KEY `#{index_name}` (#{index_options}) USING BTREE"
+ end.join(', ')
+ end
+ if options[:unique_indexes]
+ s << ", "
+ s << options[:unique_indexes].map do |index_name, index_options|
+ "UNIQUE KEY `#{index_name}` (#{index_options})"
+ end.join(', ')
+ end
+ if options[:primary_key]
+ s << ", PRIMARY KEY (#{options[:primary_key]})"
+ else
+ s << ", PRIMARY KEY (`id`)"
+ end
+
+ s << ") ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;"
+ end
+ end
+
+
+ end
+ end
+end
diff --git a/lib/postal/message_db/statistics.rb b/lib/postal/message_db/statistics.rb
new file mode 100644
index 0000000..e34db8c
--- /dev/null
+++ b/lib/postal/message_db/statistics.rb
@@ -0,0 +1,58 @@
+module Postal
+ module MessageDB
+ class Statistics
+
+ def initialize(database)
+ @database = database
+ end
+
+ STATS_GAPS = {:hourly => :hour, :daily => :day, :monthly => :month, :yearly => :year}
+ COUNTERS = [:incoming, :outgoing, :spam, :bounces, :held]
+
+ #
+ # Increment an appropriate counter
+ #
+ def increment_one(type, field, time = Time.now)
+ time = time.utc
+ initial_values = COUNTERS.map do |c|
+ field.to_sym == c ? 1 : 0
+ end
+
+ time_i = time.send("beginning_of_#{STATS_GAPS[type]}").utc.to_i
+ sql_query = "INSERT INTO `#{@database.database_name}`.`stats_#{type}` (time, #{COUNTERS.join(', ')})"
+ sql_query << " VALUES (#{time_i}, #{initial_values.join(', ')})"
+ sql_query << " ON DUPLICATE KEY UPDATE #{field} = #{field} + 1"
+ @database.query(sql_query)
+ end
+
+ #
+ # Increment all stats counters
+ #
+ def increment_all(time, field)
+ STATS_GAPS.keys.each do |type|
+ increment_one(type, field, time)
+ end
+ end
+
+ #
+ # Get a statistic (or statistics)
+ #
+ def get(type, counters, start_date = Time.now, quantity = 10)
+ date = start_date.utc
+ items = quantity.times.each_with_object({}) do |i, hash|
+ hash[(start_date - i.send(STATS_GAPS[type])).send("beginning_of_#{STATS_GAPS[type]}").utc] = counters.each_with_object({}) do |c, h|
+ h[c] = 0
+ end
+ end
+ @database.select("stats_#{type}", :where => {:time => items.keys.map(&:to_i)}, :fields => [:time] | counters).each do |data|
+ time = Time.at(data.delete('time'))
+ data.each do |key, value|
+ items[time][key.to_sym] = value
+ end
+ end
+ items.to_a.reverse
+ end
+
+ end
+ end
+end
diff --git a/lib/postal/message_db/suppression_list.rb b/lib/postal/message_db/suppression_list.rb
new file mode 100644
index 0000000..cf05e2e
--- /dev/null
+++ b/lib/postal/message_db/suppression_list.rb
@@ -0,0 +1,38 @@
+module Postal
+ module MessageDB
+ class SuppressionList
+
+ def initialize(database)
+ @database = database
+ end
+
+ def add(type, address, options = {})
+ keep_until = (options[:days] || 30).days.from_now.to_f
+ if existing = @database.select('suppressions', :where => {:type => type, :address => address}, :limit =>1).first
+ reason = options[:reason] || existing['reason']
+ @database.update('suppressions', {:reason => reason, :keep_until => keep_until}, :where => {:id => existing['id']})
+ else
+ @database.insert('suppressions', {:type => type, :address => address, :reason => options[:reason], :timestamp => Time.now.to_f, :keep_until => keep_until})
+ end
+ true
+ end
+
+ def get(type, address)
+ @database.select('suppressions', :where => {:type => type, :address => address, :keep_until => {:greater_than_or_equal_to => Time.now.to_f}}, :limit => 1).first
+ end
+
+ def all_with_pagination(page)
+ @database.select_with_pagination(:suppressions, page, :order => :timestamp, :direction => 'desc')
+ end
+
+ def remove(type, address)
+ @database.delete('suppressions', :where => {:type => type, :address => address}) > 0
+ end
+
+ def prune
+ @database.delete('suppressions', :where => {:keep_until => {:less_than => Time.now.to_f}}) || 0
+ end
+
+ end
+ end
+end
diff --git a/lib/postal/message_db/webhooks.rb b/lib/postal/message_db/webhooks.rb
new file mode 100644
index 0000000..15f84d4
--- /dev/null
+++ b/lib/postal/message_db/webhooks.rb
@@ -0,0 +1,88 @@
+module Postal
+ module MessageDB
+ class Webhooks
+
+ def initialize(database)
+ @database = database
+ end
+
+ def record(attributes = {})
+ @database.insert(:webhook_requests, attributes)
+ end
+
+ def list(page)
+ result = @database.select_with_pagination(:webhook_requests, page, :order => :timestamp, :direction => 'desc')
+ result[:records] = result[:records].map { |i| Request.new(i) }
+ result
+ end
+
+ def find(uuid)
+ request = @database.select(:webhook_requests, :where => {:uuid => uuid}).first || raise(RequestNotFound, "No request found with UUID '#{uuid}'")
+ Request.new(request)
+ end
+
+ def prune
+ if last = @database.select(:webhook_requests, :where => {:timestamp => {:less_than => 10.days.ago.to_f}}, :order => 'timestamp', :direction => 'desc', :limit => 1, :fields => ['id']).first
+ @database.delete(:webhook_requests, :where => {:id => {:less_than_or_equal_to => last['id']}})
+ end
+ end
+
+ class RequestNotFound < Postal::Error
+ end
+
+ class Request
+ def initialize(attributes)
+ @attributes = attributes
+ end
+
+ def [](name)
+ @attributes[name.to_s]
+ end
+
+ def timestamp
+ Time.at(@attributes['timestamp'])
+ end
+
+ def event
+ @attributes['event']
+ end
+
+ def status_code
+ @attributes['status_code']
+ end
+
+ def url
+ @attributes['url']
+ end
+
+ def uuid
+ @attributes['uuid']
+ end
+
+ def payload
+ @attributes['payload']
+ end
+
+ def pretty_payload
+ @pretty_payload ||= begin
+ json = JSON.parse(self.payload)
+ JSON.pretty_unparse(json)
+ end
+ end
+
+ def body
+ @attributes['body']
+ end
+
+ def attempt
+ @attributes['attempt']
+ end
+
+ def will_retry?
+ @attributes['will_retry'] == 1
+ end
+ end
+
+ end
+ end
+end
diff --git a/lib/postal/message_parser.rb b/lib/postal/message_parser.rb
new file mode 100644
index 0000000..c85af20
--- /dev/null
+++ b/lib/postal/message_parser.rb
@@ -0,0 +1,144 @@
+module Postal
+ class MessageParser
+
+ URL_REGEX = /(?(?https?)\:\/\/(?[A-Za-z0-9\-\.]+)(?\/[A-Za-z0-9\/\.\/\+\?\&\-\_\%\=\~\:\;]+)?+)/
+
+ def initialize(message)
+ @message = message
+ @actioned = false
+ @tracked_links = 0
+ @tracked_images = 0
+ @domain = @message.server.track_domains.where(:domain => @message.domain, :dns_status => "OK").first
+
+ if @domain
+ @parsed_output = generate
+ end
+ end
+
+ attr_reader :tracked_links
+ attr_reader :tracked_images
+
+ def actioned?
+ @actioned || @tracked_links > 0 || @tracked_images > 0
+ end
+
+ def new_body
+ @parsed_output.split("\r\n\r\n", 2)[1]
+ end
+
+ private
+
+ def generate
+ @mail = Mail.new(@message.raw_message)
+ @original_message = @message.raw_message
+ if @mail.parts.empty?
+ if @mail.mime_type
+ if @mail.mime_type =~ /text\/plain/
+ @mail.body = parse(@mail.body.decoded.dup, :text)
+ @mail.content_transfer_encoding = nil
+ @mail.charset = 'UTF-8'
+ elsif @mail.mime_type =~ /text\/html/
+ @mail.body = parse(@mail.body.decoded.dup, :html)
+ @mail.content_transfer_encoding = nil
+ @mail.charset = 'UTF-8'
+ end
+ end
+ else
+ parse_parts(@mail.parts)
+ end
+ @mail.to_s
+ rescue => e
+ if Rails.env.development?
+ raise
+ else
+ Raven.capture_exception(e)
+ @actioned = false
+ @tracked_links = 0
+ @tracked_images = 0
+ @original_message
+ end
+ end
+
+ def parse_parts(parts)
+ parts.each do |part|
+ if part.content_type =~ /text\/html/
+ part.body = parse(part.body.decoded.dup, :html)
+ part.content_transfer_encoding = nil
+ part.charset = 'UTF-8'
+ elsif part.content_type =~ /text\/plain/
+ part.body = parse(part.body.decoded.dup, :text)
+ part.content_transfer_encoding = nil
+ part.charset = 'UTF-8'
+ elsif part.content_type =~ /multipart\/alternative/
+ unless part.parts.empty?
+ parse_parts(part.parts)
+ end
+ end
+ end
+ end
+
+ def parse(part, type = nil)
+ if @domain.track_clicks?
+ part = insert_links(part, type)
+ end
+
+ if @domain.track_loads? && type == :html
+ part = insert_tracking_image(part)
+ end
+
+ part
+ end
+
+ def insert_links(part, type = nil)
+ if type == :text
+ part.gsub!(/#{URL_REGEX}/) do
+ if track_domain?($~[:domain])
+ @tracked_links += 1
+ token = @message.create_link($~[:url])
+ "#{domain}/#{@message.server.token}/#{token}"
+ else
+ $&
+ end
+ end
+ end
+
+ if type == :html
+ part.gsub!(/href=([\'\"])(#{URL_REGEX})[\'\"]/) do
+ if track_domain?($~[:domain])
+ @tracked_links += 1
+ token = @message.create_link($~[:url])
+ "href='#{domain}/#{@message.server.token}/#{token}'"
+ else
+ $&
+ end
+ end
+ end
+
+ part.gsub!(/(https?)\+notrack\:\/\//) do
+ @actioned = true
+ "#{$1}://"
+ end
+
+ part
+ end
+
+ def insert_tracking_image(part)
+ @tracked_images += 1
+ container = "
"
+ if part =~ /\<\/body\>/
+ part.gsub("
+
+