ditty 0.4.1 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/Readme.md +3 -12
  4. data/ditty.gemspec +4 -1
  5. data/exe/ditty +4 -0
  6. data/lib/ditty/cli.rb +44 -0
  7. data/lib/ditty/controllers/application.rb +3 -4
  8. data/lib/ditty/controllers/component.rb +38 -14
  9. data/lib/ditty/controllers/main.rb +115 -34
  10. data/lib/ditty/controllers/users.rb +7 -10
  11. data/lib/ditty/db.rb +6 -1
  12. data/lib/ditty/emails/base.rb +74 -0
  13. data/lib/ditty/emails/forgot_password.rb +15 -0
  14. data/lib/ditty/helpers/authentication.rb +2 -2
  15. data/lib/ditty/helpers/component.rb +7 -4
  16. data/lib/ditty/helpers/views.rb +5 -2
  17. data/lib/ditty/listener.rb +41 -7
  18. data/lib/ditty/models/user.rb +8 -4
  19. data/lib/ditty/policies/identity_policy.rb +2 -2
  20. data/lib/ditty/rake_tasks.rb +6 -5
  21. data/lib/ditty/services/authentication.rb +55 -0
  22. data/lib/ditty/services/email.rb +18 -20
  23. data/lib/ditty/services/logger.rb +10 -8
  24. data/lib/ditty/services/pagination_wrapper.rb +82 -0
  25. data/lib/ditty/services/settings.rb +45 -0
  26. data/lib/ditty/version.rb +1 -1
  27. data/migrate/20180307_password_reset.rb +10 -0
  28. data/views/audit_logs/index.haml +1 -1
  29. data/views/emails/base.haml +2 -0
  30. data/views/emails/forgot_password.haml +26 -0
  31. data/views/emails/layouts/action.haml +68 -0
  32. data/views/emails/layouts/alert.haml +88 -0
  33. data/views/emails/layouts/billing.haml +108 -0
  34. data/views/identity/forgot.haml +16 -0
  35. data/views/identity/login.haml +16 -5
  36. data/views/identity/register.haml +8 -0
  37. data/views/identity/reset.haml +20 -0
  38. data/views/layout.haml +2 -0
  39. metadata +62 -5
  40. data/lib/ditty/helpers/wisper.rb +0 -14
@@ -1,45 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'mail'
4
+ require 'active_support/inflector'
4
5
  require 'ditty/services/logger'
6
+ require 'ditty/services/settings'
5
7
 
6
8
  module Ditty
7
9
  module Services
8
10
  module Email
9
- CONFIG = './config/email.yml'.freeze
10
-
11
11
  class << self
12
- def method_missing(method, *args, &block)
13
- return super unless respond_to_missing?(method)
14
- config!
15
- Mail.send(method, *args, &block)
16
- end
12
+ include ActiveSupport::Inflector
17
13
 
18
- def respond_to_missing?(method, _include_private = false)
19
- Mail.respond_to? method
14
+ def config!
15
+ cfg = config
16
+ Mail.defaults do
17
+ delivery_method cfg[:delivery_method].to_sym, (cfg[:options] || {})
18
+ end
20
19
  end
21
20
 
22
- def config
23
- @config ||= symbolize_keys File.exist?(CONFIG) ? YAML.load_file(CONFIG) : default
21
+ def deliver(email, to = nil, options = {})
22
+ config!
23
+ options[:to] ||= to unless to.nil?
24
+ email = from_symbol(email, options) if email.is_a? Symbol
25
+ email.deliver!
24
26
  end
25
27
 
26
28
  private
27
29
 
28
- def config!
29
- cfg = config
30
- Mail.defaults do
31
- delivery_method cfg[:delivery_method].to_sym, (cfg[:options] || {})
32
- end
30
+ def config
31
+ default.merge Ditty::Services::Settings.values(:email) || {}
33
32
  end
34
33
 
35
34
  def default
36
35
  { delivery_method: :logger, logger: Ditty::Services::Logger.instance }
37
36
  end
38
37
 
39
- def symbolize_keys(hash)
40
- return hash.map { |v| symbolize_keys(v) } if hash.is_a? Array
41
- return hash unless hash.is_a? Hash
42
- Hash[hash.map { |k, v| [k.to_sym, symbolize_keys(v)] }]
38
+ def from_symbol(email, options)
39
+ require "ditty/emails/#{email}"
40
+ constantize("Ditty::Emails::#{classify(email)}").new(options)
43
41
  end
44
42
  end
45
43
  end
@@ -4,23 +4,25 @@ require 'logger'
4
4
  require 'yaml'
5
5
  require 'singleton'
6
6
  require 'active_support/inflector'
7
+ require 'ditty/services/settings'
8
+ require 'active_support/core_ext/object/blank'
7
9
 
8
10
  module Ditty
9
11
  module Services
10
12
  class Logger
11
13
  include Singleton
12
14
 
13
- CONFIG = './config/logger.yml'.freeze
14
15
  attr_reader :loggers
15
16
 
16
17
  def initialize
17
18
  @loggers = []
18
- config.each do |values|
19
- klass = values['class'].constantize
20
- opts = tr(values['options']) || nil
19
+ return if config[:loggers].blank?
20
+ config[:loggers].each do |values|
21
+ klass = values[:class].constantize
22
+ opts = tr(values[:options]) || nil
21
23
  logger = klass.new(opts)
22
- if values['level']
23
- logger.level = klass.const_get(values['level'].to_sym)
24
+ if values[:level]
25
+ logger.level = klass.const_get(values[:level].to_sym)
24
26
  end
25
27
  @loggers << logger
26
28
  end
@@ -37,7 +39,7 @@ module Ditty
37
39
  private
38
40
 
39
41
  def config
40
- @config ||= File.exist?(CONFIG) ? YAML.load_file(CONFIG) : default
42
+ default.merge Ditty::Services::Settings.values(:logger) || {}
41
43
  end
42
44
 
43
45
  def tr(val)
@@ -48,7 +50,7 @@ module Ditty
48
50
  end
49
51
 
50
52
  def default
51
- [{ 'name' => 'default', 'class' => 'Logger' }]
53
+ { loggers: [{ name: 'default', class: 'Logger' }] }
52
54
  end
53
55
  end
54
56
  end
@@ -0,0 +1,82 @@
1
+ module Ditty
2
+ module Services
3
+ class PaginationWrapper
4
+ attr_reader :list
5
+
6
+ def initialize(list)
7
+ @list = list
8
+ end
9
+
10
+ def last_page?
11
+ if list.respond_to? :'last_page?'
12
+ list.last_page?
13
+ else
14
+ list.current_page == list.total_pages
15
+ end
16
+ end
17
+
18
+ def first_page?
19
+ if list.respond_to? :'first_page?'
20
+ list.first_page?
21
+ else
22
+ list.current_page == 0
23
+ end
24
+ end
25
+
26
+ def prev_page
27
+ if list.respond_to? :prev_page
28
+ list.prev_page
29
+ else
30
+ list.previous_page
31
+ end
32
+ end
33
+
34
+ def page_count
35
+ if list.respond_to? :page_count
36
+ list.page_count
37
+ else
38
+ list.total_pages
39
+ end
40
+ end
41
+
42
+ def page_size
43
+ if list.respond_to? :page_size
44
+ list.page_size
45
+ else
46
+ list.per_page
47
+ end
48
+ end
49
+
50
+ def pagination_record_count
51
+ if list.respond_to? :pagination_record_count
52
+ list.pagination_record_count
53
+ else
54
+ list.total_entries
55
+ end
56
+ end
57
+
58
+ def method_missing(method, *args)
59
+ return super unless respond_to_missing?(method)
60
+
61
+ list.send(method, *args)
62
+ end
63
+
64
+ def respond_to_missing?(method, _include_private = false)
65
+ list.respond_to? method
66
+ end
67
+
68
+ def current_page_record_range
69
+ if list.respond_to? :current_page_record_range
70
+ list.current_page_record_range
71
+ else
72
+ return (0..0) if list.current_page > page_count
73
+
74
+ a = 1 + (list.current_page - 1) * page_size
75
+ b = a + page_size - 1
76
+ b = pagination_record_count if b > pagination_record_count
77
+ a..b
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'erb'
5
+ require 'active_support/core_ext/hash/deep_merge'
6
+ require 'active_support/core_ext/hash/keys'
7
+
8
+ module Ditty
9
+ module Services
10
+ module Settings
11
+ CONFIG_FOLDER = './config'.freeze
12
+ CONFIG_FILE = "#{CONFIG_FOLDER}/settings.yml".freeze
13
+
14
+ class << self
15
+ def [](key)
16
+ values(key.to_sym)
17
+ end
18
+
19
+ def values(scope = :settings)
20
+ @values ||= begin
21
+ v = Hash.new do |h, k|
22
+ h[k] = if File.file?("#{CONFIG_FOLDER}/#{k}.yml")
23
+ read("#{CONFIG_FOLDER}/#{k}.yml")
24
+ elsif k != :settings && h[:settings].key?(k)
25
+ h[:settings][k]
26
+ end
27
+ h[k]
28
+ end
29
+ v[:settings] = File.file?(CONFIG_FILE) ? read(CONFIG_FILE) : {}
30
+ v
31
+ end
32
+ @values[scope]
33
+ end
34
+
35
+ attr_writer :values
36
+
37
+ def read(filename)
38
+ base = YAML.safe_load(ERB.new(File.read(filename)).result).deep_symbolize_keys
39
+ base.deep_merge!(base[ENV['APP_ENV'].to_sym]) unless ENV['APP_ENV'].nil? || base[ENV['APP_ENV'].to_sym].nil?
40
+ base
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
data/lib/ditty/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ditty
4
- VERSION = '0.4.1'.freeze
4
+ VERSION = '0.6.0'.freeze
5
5
  end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ change do
5
+ alter_table :identities do
6
+ add_column :reset_token, String
7
+ add_column :reset_requested, DateTime
8
+ end
9
+ end
10
+ end
@@ -16,7 +16,7 @@
16
16
  %tr
17
17
  %td
18
18
  -if entity.user
19
- %a{ href: "/users/#{entity.user.id}" }= entity.user.email
19
+ %a{ href: "#{settings.map_path}/users/#{entity.user.id}" }= entity.user.email
20
20
  -else
21
21
  None
22
22
  %td
@@ -0,0 +1,2 @@
1
+ - if defined? content
2
+ = content
@@ -0,0 +1,26 @@
1
+ %p
2
+ Hi #{identity.user.name}
3
+
4
+ %p
5
+ We received a request to reset the password for #{identity.username}.
6
+ You can use the button below to complete this request.
7
+ %strong
8
+ This reset is only valid for 24 hours
9
+
10
+ %p.text-center
11
+ %a{ href: reset_url }
12
+
13
+ %p
14
+ For security purposes, if you did not request this password reset,
15
+ please contact support.
16
+
17
+ %p
18
+ Thanx!
19
+
20
+ %ul
21
+ %li
22
+ Requesting IP:
23
+ = request.ip
24
+ %li
25
+ Requesting User Agent:
26
+ = request.user_agent
@@ -0,0 +1,68 @@
1
+ !!!
2
+ %html{:style => "font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;", :xmlns => "http://www.w3.org/1999/xhtml"}
3
+ %head
4
+ %meta{:content => "width=device-width", :name => "viewport"}/
5
+ %meta{:content => "text/html; charset=UTF-8", "http-equiv" => "Content-Type"}/
6
+ %title Actionable emails e.g. reset password
7
+ :css
8
+ img {
9
+ max-width: 100%;
10
+ }
11
+ body {
12
+ -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; height: 100%; line-height: 1.6em;
13
+ }
14
+ body {
15
+ background-color: #f6f6f6;
16
+ }
17
+ @media only screen and (max-width: 640px) {
18
+ body {
19
+ padding: 0 !important;
20
+ }
21
+ h1 {
22
+ font-weight: 800 !important; margin: 20px 0 5px !important;
23
+ }
24
+ h2 {
25
+ font-weight: 800 !important; margin: 20px 0 5px !important;
26
+ }
27
+ h3 {
28
+ font-weight: 800 !important; margin: 20px 0 5px !important;
29
+ }
30
+ h4 {
31
+ font-weight: 800 !important; margin: 20px 0 5px !important;
32
+ }
33
+ h1 {
34
+ font-size: 22px !important;
35
+ }
36
+ h2 {
37
+ font-size: 18px !important;
38
+ }
39
+ h3 {
40
+ font-size: 16px !important;
41
+ }
42
+ .container {
43
+ padding: 0 !important; width: 100% !important;
44
+ }
45
+ .content {
46
+ padding: 0 !important;
47
+ }
48
+ .content-wrap {
49
+ padding: 10px !important;
50
+ }
51
+ .invoice {
52
+ width: 100% !important;
53
+ }
54
+ }
55
+ %body{:bgcolor => "#f6f6f6", :itemscope => "", :itemtype => "http://schema.org/EmailMessage", :style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; height: 100%; line-height: 1.6em; background-color: #f6f6f6; margin: 0;"}
56
+ %table.body-wrap{:bgcolor => "#f6f6f6", :style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; width: 100%; background-color: #f6f6f6; margin: 0;"}
57
+ %tr{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"}
58
+ %td{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0;", :valign => "top"}
59
+ %td.container{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; display: block !important; max-width: 600px !important; clear: both !important; margin: 0 auto;", :valign => "top", :width => "600"}
60
+ .content{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; max-width: 600px; display: block; margin: 0 auto; padding: 20px;"}
61
+ = content
62
+ - if defined? footer
63
+ .footer{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; width: 100%; clear: both; color: #999; margin: 0; padding: 20px;"}
64
+ %table{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;", :width => "100%"}
65
+ %tr{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"}
66
+ %td.aligncenter.content-block{:align => "center", :style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 12px; vertical-align: top; color: #999; text-align: center; margin: 0; padding: 0 0 20px;", :valign => "top"}
67
+ = footer
68
+ %td{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0;", :valign => "top"}
@@ -0,0 +1,88 @@
1
+ !!!
2
+ %html{:style => "font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;", :xmlns => "http://www.w3.org/1999/xhtml"}
3
+ %head
4
+ %meta{:content => "width=device-width", :name => "viewport"}/
5
+ %meta{:content => "text/html; charset=UTF-8", "http-equiv" => "Content-Type"}/
6
+ %title Alerts e.g. approaching your limit
7
+ :css
8
+ img {
9
+ max-width: 100%;
10
+ }
11
+ body {
12
+ -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; height: 100%; line-height: 1.6em;
13
+ }
14
+ body {
15
+ background-color: #f6f6f6;
16
+ }
17
+ @media only screen and (max-width: 640px) {
18
+ body {
19
+ padding: 0 !important;
20
+ }
21
+ h1 {
22
+ font-weight: 800 !important; margin: 20px 0 5px !important;
23
+ }
24
+ h2 {
25
+ font-weight: 800 !important; margin: 20px 0 5px !important;
26
+ }
27
+ h3 {
28
+ font-weight: 800 !important; margin: 20px 0 5px !important;
29
+ }
30
+ h4 {
31
+ font-weight: 800 !important; margin: 20px 0 5px !important;
32
+ }
33
+ h1 {
34
+ font-size: 22px !important;
35
+ }
36
+ h2 {
37
+ font-size: 18px !important;
38
+ }
39
+ h3 {
40
+ font-size: 16px !important;
41
+ }
42
+ .container {
43
+ padding: 0 !important; width: 100% !important;
44
+ }
45
+ .content {
46
+ padding: 0 !important;
47
+ }
48
+ .content-wrap {
49
+ padding: 10px !important;
50
+ }
51
+ .invoice {
52
+ width: 100% !important;
53
+ }
54
+ }
55
+ %body{:bgcolor => "#f6f6f6", :itemscope => "", :itemtype => "http://schema.org/EmailMessage", :style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; height: 100%; line-height: 1.6em; background-color: #f6f6f6; margin: 0;"}
56
+ %table.body-wrap{:bgcolor => "#f6f6f6", :style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; width: 100%; background-color: #f6f6f6; margin: 0;"}
57
+ %tr{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"}
58
+ %td{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0;", :valign => "top"}
59
+ %td.container{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; display: block !important; max-width: 600px !important; clear: both !important; margin: 0 auto;", :valign => "top", :width => "600"}
60
+ .content{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; max-width: 600px; display: block; margin: 0 auto; padding: 20px;"}
61
+ %table.main{:bgcolor => "#fff", :cellpadding => "0", :cellspacing => "0", :style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; border-radius: 3px; background-color: #fff; margin: 0; border: 1px solid #e9e9e9;", :width => "100%"}
62
+ %tr{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"}
63
+ %td.alert.alert-warning{:align => "center", :bgcolor => "#FF9F00", :style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 16px; vertical-align: top; color: #fff; font-weight: 500; text-align: center; border-radius: 3px 3px 0 0; background-color: #FF9F00; margin: 0; padding: 20px;", :valign => "top"}
64
+ Warning: You're approaching your limit. Please upgrade.
65
+ %tr{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"}
66
+ %td.content-wrap{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 20px;", :valign => "top"}
67
+ %table{:cellpadding => "0", :cellspacing => "0", :style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;", :width => "100%"}
68
+ %tr{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"}
69
+ %td.content-block{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;", :valign => "top"}
70
+ You have
71
+ %strong{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"} 1 free report
72
+ remaining.
73
+ %tr{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"}
74
+ %td.content-block{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;", :valign => "top"}
75
+ Add your credit card now to upgrade your account to a premium plan to ensure you don't miss out on any reports.
76
+ %tr{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"}
77
+ %td.content-block{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;", :valign => "top"}
78
+ %a.btn-primary{:href => "http://www.mailgun.com", :style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; color: #FFF; text-decoration: none; line-height: 2em; font-weight: bold; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; text-transform: capitalize; background-color: #348eda; margin: 0; border-color: #348eda; border-style: solid; border-width: 10px 20px;"} Upgrade my account
79
+ %tr{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"}
80
+ %td.content-block{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;", :valign => "top"}
81
+ Thanks for choosing Acme Inc.
82
+ .footer{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; width: 100%; clear: both; color: #999; margin: 0; padding: 20px;"}
83
+ %table{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;", :width => "100%"}
84
+ %tr{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"}
85
+ %td.aligncenter.content-block{:align => "center", :style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 12px; vertical-align: top; color: #999; text-align: center; margin: 0; padding: 0 0 20px;", :valign => "top"}
86
+ %a{:href => "http://www.mailgun.com", :style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 12px; color: #999; text-decoration: underline; margin: 0;"} Unsubscribe
87
+ from these alerts.
88
+ %td{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0;", :valign => "top"}