maily_herald 0.0.1 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (93) hide show
  1. checksums.yaml +15 -0
  2. data/.gitignore +10 -4
  3. data/.rspec +5 -0
  4. data/Gemfile +1 -12
  5. data/Gemfile.lock +129 -82
  6. data/Guardfile +25 -0
  7. data/LICENSE +10 -0
  8. data/README.md +346 -0
  9. data/Rakefile +5 -0
  10. data/app/controllers/maily_herald/tokens_controller.rb +11 -0
  11. data/app/helpers/maily_herald/tokens_helper.rb +17 -0
  12. data/app/mailers/maily_herald/mailer.rb +91 -0
  13. data/app/models/maily_herald/dispatch.rb +76 -0
  14. data/app/models/maily_herald/list.rb +99 -0
  15. data/app/models/maily_herald/log.rb +67 -0
  16. data/app/models/maily_herald/mailing.rb +139 -7
  17. data/app/models/maily_herald/one_time_mailing.rb +26 -0
  18. data/app/models/maily_herald/periodical_mailing.rb +145 -0
  19. data/app/models/maily_herald/sequence.rb +169 -2
  20. data/app/models/maily_herald/sequence_mailing.rb +71 -0
  21. data/app/models/maily_herald/subscription.rb +67 -0
  22. data/bin/maily_herald +16 -0
  23. data/config/database.yml +5 -0
  24. data/config/locales/en.yml +6 -11
  25. data/config/routes.rb +10 -0
  26. data/config/spring.rb +1 -0
  27. data/db/migrate/20150205120443_create_maily_herald_tables.rb +53 -0
  28. data/db/migrate_legacy/20130711124555_create_maily_herald_tables.rb +67 -0
  29. data/db/migrate_legacy/20140612101023_create_lists.rb +33 -0
  30. data/lib/generators/maily_herald/install_generator.rb +3 -3
  31. data/lib/generators/templates/README +2 -0
  32. data/lib/generators/templates/maily_herald.rb +1 -0
  33. data/lib/maily_herald.rb +345 -23
  34. data/lib/maily_herald/autonaming.rb +34 -0
  35. data/lib/maily_herald/capistrano.rb +5 -0
  36. data/lib/maily_herald/capistrano/tasks.cap +67 -0
  37. data/lib/maily_herald/capistrano/tasks2.rb +20 -0
  38. data/lib/maily_herald/cli.rb +293 -0
  39. data/lib/maily_herald/condition_evaluator.rb +82 -0
  40. data/lib/maily_herald/config.rb +5 -0
  41. data/lib/maily_herald/context.rb +223 -77
  42. data/lib/maily_herald/engine.rb +17 -0
  43. data/lib/maily_herald/logging.rb +90 -0
  44. data/lib/maily_herald/manager.rb +53 -0
  45. data/lib/maily_herald/model_extensions.rb +15 -0
  46. data/lib/maily_herald/template_renderer.rb +16 -0
  47. data/lib/maily_herald/utils.rb +78 -5
  48. data/lib/maily_herald/version.rb +1 -1
  49. data/maily_herald.gemspec +17 -9
  50. data/spec/controllers/maily_herald/tokens_controller_spec.rb +81 -0
  51. data/spec/dummy/Guardfile +35 -0
  52. data/spec/dummy/app/mailers/test_mailer.rb +11 -0
  53. data/spec/dummy/app/models/product.rb +2 -0
  54. data/spec/dummy/app/models/user.rb +4 -0
  55. data/spec/dummy/app/views/test_mailer/sample_mail.text.erb +1 -0
  56. data/spec/dummy/bin/rails +10 -0
  57. data/spec/dummy/bin/rake +7 -0
  58. data/spec/dummy/bin/rspec +7 -0
  59. data/spec/dummy/bin/spring +18 -0
  60. data/spec/dummy/config/application.rb +1 -1
  61. data/spec/dummy/config/environments/development.rb +1 -0
  62. data/spec/dummy/config/environments/test.rb +1 -0
  63. data/spec/dummy/config/initializers/maily_herald.rb +103 -0
  64. data/spec/dummy/config/locales/maily_herald.en.yml +28 -0
  65. data/spec/dummy/db/migrate/20130723074347_create_users.rb +18 -0
  66. data/spec/dummy/db/schema.rb +82 -0
  67. data/spec/factories/products.rb +5 -0
  68. data/spec/factories/users.rb +11 -0
  69. data/spec/lib/context_spec.rb +41 -0
  70. data/spec/lib/maily_herald_spec.rb +32 -0
  71. data/spec/lib/utils_spec.rb +48 -0
  72. data/spec/mailers/maily_herald/mailer_spec.rb +38 -0
  73. data/spec/models/maily_herald/list_spec.rb +64 -0
  74. data/spec/models/maily_herald/log_spec.rb +36 -0
  75. data/spec/models/maily_herald/mailing_spec.rb +34 -0
  76. data/spec/models/maily_herald/one_time_mailing_spec.rb +112 -0
  77. data/spec/models/maily_herald/periodical_mailing_spec.rb +339 -0
  78. data/spec/models/maily_herald/sequence_mailing_spec.rb +18 -0
  79. data/spec/models/maily_herald/sequence_spec.rb +429 -0
  80. data/spec/models/maily_herald/subscription_spec.rb +32 -0
  81. data/spec/spec_helper.rb +31 -11
  82. metadata +199 -54
  83. data/MIT-LICENSE +0 -20
  84. data/README.rdoc +0 -3
  85. data/app/assets/images/maily_herald/.gitkeep +0 -0
  86. data/app/assets/javascripts/maily_herald/application.js +0 -15
  87. data/app/assets/stylesheets/maily_herald/application.css +0 -13
  88. data/app/helpers/maily_herald/application_helper.rb +0 -4
  89. data/app/helpers/maily_herald_helper.rb +0 -9
  90. data/app/models/maily_herald/mailing_record.rb +0 -6
  91. data/app/views/layouts/maily_herald/application.html.erb +0 -14
  92. data/db/migrate/20130711124555_create_maily_herald_tables.rb +0 -38
  93. data/lib/maily_herald/worker.rb +0 -15
@@ -0,0 +1,5 @@
1
+ require 'maily_herald'
2
+ module MailyHerald
3
+ module Config
4
+ end
5
+ end
@@ -1,79 +1,225 @@
1
1
  module MailyHerald
2
- class Context
3
- class Drop < Liquid::Drop
4
- def initialize attributes, item
5
- @attributes = attributes
6
- @item = item
7
- end
8
-
9
- def has_key?(name)
10
- name = name.to_sym
11
-
12
- @attributes.has_key? name
13
- end
14
-
15
- def invoke_drop name
16
- name = name.to_sym
17
-
18
- #@attributes[name].try(:call, @item)
19
- @attributes[name].call(@item)
20
- end
21
-
22
- alias :[] :invoke_drop
23
- end
24
-
25
- attr_accessor :entity
26
-
27
- def scope &block
28
- if block_given?
29
- @scope = block
30
- else
31
- @scope.call
32
- end
33
- end
34
-
35
- def destination &block
36
- if block_given?
37
- @destination = block
38
- else
39
- @destination
40
- end
41
- end
42
-
43
- def attribute name, &block
44
- name = name.to_sym
45
-
46
- @attributes ||= {}
47
- if block_given?
48
- @attributes[name] = block
49
- else
50
- @attributes[name]
51
- end
52
- end
53
-
54
- def attribute_names
55
- @attributes.keys
56
- end
57
-
58
- def model
59
- @scope.call.table.engine
60
- end
61
-
62
- def extract_attributes hash = @attributes, collect = false
63
- hash.map do |k, v|
64
- v.is_a?(Hash) ? extract_attributes(v, (k == "items_attributes")) : (collect ? v : nil)
65
- end.compact.flatten
66
- end
67
-
68
- def each &block
69
- @scope.call.each do |item|
70
- drop = Drop.new(@attributes, item)
71
- block.call(item, drop)
72
- end
73
- end
74
-
75
- def drop_for item
76
- Drop.new(@attributes, item)
77
- end
78
- end
2
+
3
+ # Abstraction layer for accessing collections of Entities and their attributes.
4
+ # Information provided by scope is used while sending {MailyHerald::Mailing mailings}.
5
+ #
6
+ # {Context} defines following:
7
+ #
8
+ # * Entity scope - +ActiveRecord::Relation+, list of Entities that will be returned
9
+ # by {Context}.
10
+ # * Entity model name - deducted automatically from scope.
11
+ # * Entity attributes - Defined as procs that can be evaluated for every item of
12
+ # the scope (single Entity).
13
+ #
14
+ # * Entity email - defined as proc or string/symbol (email method name).
15
+ #
16
+ class Context
17
+
18
+ # Context Attributes drop definition for Liquid
19
+ class Drop < Liquid::Drop
20
+ def initialize attrs
21
+ @attrs = attrs
22
+ end
23
+
24
+ def has_key?(name)
25
+ name = name.to_s
26
+
27
+ @attrs.has_key? name
28
+ end
29
+
30
+ def invoke_drop name
31
+ name = name.to_s
32
+
33
+ if @attrs.has_key? name
34
+ if @attrs[name].is_a? Hash
35
+ Drop.new(@attrs[name])
36
+ else
37
+ @attrs[name].call
38
+ end
39
+ else
40
+ nil
41
+ end
42
+ end
43
+
44
+ alias :[] :invoke_drop
45
+ end
46
+
47
+ class Attributes
48
+ def initialize block
49
+ @attrs = {}
50
+ @node = @parent_node = @attrs
51
+ @block = block
52
+ end
53
+
54
+ def setup entity = nil, subscription = nil
55
+ if entity
56
+ @attrs["subscription"] = Proc.new{ subscription } if subscription
57
+ instance_exec entity, &@block
58
+ else
59
+ instance_eval &@block
60
+ end
61
+ end
62
+
63
+ def attribute_group name, &block
64
+ @parent_node = @node
65
+ @parent_node[name.to_s] ||= {}
66
+ @node = @parent_node[name.to_s]
67
+ yield
68
+ @node = @parent_node
69
+ end
70
+
71
+ def attribute name, &block
72
+ @node[name.to_s] = block
73
+ end
74
+
75
+ def for_drop
76
+ @attrs
77
+ end
78
+
79
+ def method_missing(m, *args, &block)
80
+ true
81
+ end
82
+ end
83
+
84
+ # Friendly name of the {Context}.
85
+ #
86
+ # Displayed ie. in the Web UI.
87
+ attr_accessor :title
88
+
89
+ # Identification name of the {Context}.
90
+ #
91
+ # This can be then used in {MailyHerald.context} method to fetch the {Context}.
92
+ #
93
+ # @see MailyHerald.context
94
+ attr_reader :name
95
+
96
+ attr_writer :destination
97
+
98
+ # Creates {Context} and sets its name.
99
+ def initialize name
100
+ @name = name
101
+ end
102
+
103
+ # Defines or returns Entity scope - collection of Entities.
104
+ #
105
+ # If block passed, it is saved as scope proc. Block has to return
106
+ # +ActiveRecord::Relation+ containing Entity objects that will belong to scope.
107
+ #
108
+ # If no block given, scope proc is called and Entity collection returned.
109
+ def scope &block
110
+ if block_given?
111
+ @scope = block
112
+ else
113
+ @scope.call
114
+ end
115
+ end
116
+
117
+ # Fetches the Entity model class based on scope.
118
+ def model
119
+ @model ||= @scope.call.klass
120
+ end
121
+
122
+ # Entity email address.
123
+ #
124
+ # Can be eitner +Proc+ or attribute name (string, symbol).
125
+ #
126
+ # If block passed, it is saved as destination proc. Block has to:
127
+ #
128
+ # * accept single Entity object,
129
+ # * return Entity email.
130
+ #
131
+ # If no block given, +destination+ attribute is returned (a string, symbol or proc).
132
+ def destination &block
133
+ if block_given?
134
+ @destination = block
135
+ else
136
+ @destination
137
+ end
138
+ end
139
+
140
+ # Returns Entity email attribute name only if it is not defined as a proc.
141
+ def destination_attribute
142
+ @destination unless @destination.respond_to?(:call)
143
+ end
144
+
145
+ # Fetches Entity's email address based on {Context} destination definition.
146
+ def destination_for entity
147
+ destination_attribute ? entity.send(@destination) : @destination.call(entity)
148
+ end
149
+
150
+ # Simply filter Entity scope by email.
151
+ #
152
+ # If destination is provided in form of Entity attribute name (not the proc),
153
+ # this method creates the scope filtered by `query` email using SQL LIKE.
154
+ #
155
+ # @param query [String] email address which is being searched.
156
+ # @return [ActiveRecord::Relation] collection filtered by email address
157
+ def scope_like query
158
+ if destination_attribute
159
+ scope.where("#{model.table_name}.#{destination_attribute} LIKE (?)", "%#{query}%")
160
+ end
161
+ end
162
+
163
+ # Returns Entity collection scope with joined {MailyHerald::Subscription}.
164
+ #
165
+ # @param list [List, Fixnum, String] {MailyHerald::List} reference
166
+ # @param mode [:inner, :outer] SQL JOIN mode
167
+ def scope_with_subscription list, mode = :inner
168
+ list_id = case list
169
+ when List
170
+ list.id
171
+ when Fixnum
172
+ list
173
+ when String
174
+ list.to_i
175
+ else
176
+ raise ArgumentError
177
+ end
178
+
179
+ join_mode = case mode
180
+ when :outer
181
+ "LEFT OUTER JOIN"
182
+ else
183
+ "INNER JOIN"
184
+ end
185
+
186
+ subscription_fields_select = Subscription.columns.collect{|c| "#{Subscription.table_name}.#{c.name} AS maily_subscription_#{c.name}"}.join(", ")
187
+
188
+ scope.select("#{model.table_name}.*, #{subscription_fields_select}").joins(
189
+ "#{join_mode} #{Subscription.table_name} ON #{Subscription.table_name}.entity_id = #{model.table_name}.id AND #{Subscription.table_name}.entity_type = '#{model.base_class.to_s}' AND #{Subscription.table_name}.list_id = '#{list_id}'"
190
+ )
191
+ end
192
+
193
+ # Sepcify or return {Context} attributes.
194
+ #
195
+ # Defines Entity attributes that can be accessed using this Context.
196
+ # Attributes defined this way are then accesible in Liquid templates
197
+ # in Generic Mailer ({MailyHerald::Mailer#generic}).
198
+ #
199
+ # If block passed, it is used to create Context Attributes.
200
+ #
201
+ # If no block given, current attributes are returned.
202
+ def attributes &block
203
+ if block_given?
204
+ @attributes = Attributes.new block
205
+ else
206
+ @attributes
207
+ end
208
+ end
209
+
210
+ # Obtains {Context} attributes in a form of (nested) +Hash+ which
211
+ # values are procs each returning single Entity attribute value.
212
+ def attributes_list
213
+ attributes = @attributes.dup
214
+ attributes.setup
215
+ attributes.for_drop
216
+ end
217
+
218
+ # Returns Liquid drop created from Context attributes.
219
+ def drop_for entity, subscription
220
+ attributes = @attributes.dup
221
+ attributes.setup entity, subscription
222
+ Drop.new(attributes.for_drop)
223
+ end
224
+ end
79
225
  end
@@ -1,5 +1,22 @@
1
1
  module MailyHerald
2
2
  class Engine < ::Rails::Engine
3
3
  isolate_namespace MailyHerald
4
+
5
+ config.generators do |g|
6
+ g.test_framework :rspec, fixture: false
7
+ g.fixture_replacement :factory_girl, dir: 'spec/factories'
8
+ end
9
+
10
+ config.to_prepare do
11
+ require_dependency 'maily_herald/model_extensions'
12
+
13
+ MailyHerald.contexts.each do|n, c|
14
+ if c.model
15
+ unless c.model.included_modules.include?(MailyHerald::ModelExtensions)
16
+ c.model.send(:include, MailyHerald::ModelExtensions)
17
+ end
18
+ end
19
+ end
20
+ end
4
21
  end
5
22
  end
@@ -0,0 +1,90 @@
1
+ require 'time'
2
+ require 'logger'
3
+
4
+ module MailyHerald
5
+ module Logging
6
+ OPTIONS = {
7
+ target: STDOUT,
8
+ level: Logger::INFO,
9
+ progname: "app"
10
+ }
11
+
12
+ module LoggerExtensions
13
+ def log_processing *args
14
+ options = args.extract_options!
15
+ mailing, entity, mail = nil
16
+ args.each do |arg|
17
+ case arg
18
+ when Mailing
19
+ mailing = arg
20
+ when ::Mail::Message
21
+ mail = arg
22
+ else
23
+ entity = arg
24
+ end
25
+ end
26
+ prefix = options.delete(:prefix)
27
+ level = options.delete(:level) || :info
28
+
29
+ log_msg = []
30
+ if entity.is_a?(Hash)
31
+ log_msg << "<#{entity[:class]}##{entity[:id]}> #{mail.try(:to)}" if entity
32
+ else
33
+ log_msg << "<#{entity.try(:class).try(:name)}##{entity.try(:id)}> #{entity} #{mail.try(:to)}" if entity
34
+ end
35
+ log_msg << "<#{mailing.try(:class).try(:name)}##{mailing.try(:id)}> #{mailing}" if mailing
36
+
37
+ send(level, [prefix, log_msg.join(", ")].compact.join(": "))
38
+ end
39
+ end
40
+
41
+ class Formatter < Logger::Formatter
42
+ def call(severity, time, program_name, message)
43
+ "#{time.utc.iso8601} #{Process.pid} [Maily##{"%3s" % program_name}] #{severity}: #{message}\n"
44
+ end
45
+ end
46
+
47
+ def self.initialize(opts = {})
48
+ oldlogger = @logger
49
+
50
+ @options ||= OPTIONS.dup
51
+ @options.merge!(opts) if opts
52
+
53
+ @logger = Logger.new(@options[:target])
54
+ @logger.level = @options[:level]
55
+ @logger.formatter = Formatter.new
56
+ @logger.progname = @options[:progname]
57
+ @logger.extend(LoggerExtensions)
58
+
59
+ oldlogger.close if oldlogger
60
+ @logger
61
+ end
62
+
63
+ def self.initialized?
64
+ !!@logger
65
+ end
66
+
67
+ def self.logger opts = {}
68
+ @logger || initialize(opts)
69
+ end
70
+
71
+ def self.logger=(log)
72
+ @logger = (log ? log : Logger.new('/dev/null'))
73
+ end
74
+
75
+ def self.options
76
+ @options || OPTIONS.dup
77
+ end
78
+
79
+ def self.safe_options
80
+ opts = self.options.dup
81
+ opts[:target] = nil if !opts[:target].is_a?(String)
82
+ opts
83
+ end
84
+
85
+ def logger
86
+ MailyHerald::Logging.logger
87
+ end
88
+
89
+ end
90
+ end
@@ -0,0 +1,53 @@
1
+ module MailyHerald
2
+ class Manager
3
+ def self.handle_trigger type, entity
4
+ mailings = Mailing.where(trigger: type)
5
+ mailings.each do |mailing|
6
+ mailing.deliver_to entity
7
+ end
8
+ end
9
+
10
+ def self.deliver mailing, entity
11
+ mailing = Mailing.find_by_name(mailing) if !mailing.is_a?(Mailing)
12
+ entity = mailing.context.scope.find(entity) if entity.is_a?(Fixnum)
13
+
14
+ mailing.deliver_to entity if mailing
15
+ end
16
+
17
+ def self.run_sequence seq
18
+ seq = Sequence.find_by_name(seq) if !seq.is_a?(Sequence)
19
+
20
+ seq.run if seq
21
+ end
22
+
23
+ def self.run_mailing mailing
24
+ mailing = Mailing.find_by_name(mailing) if !mailing.is_a?(Mailing)
25
+
26
+ mailing.run if mailing
27
+ end
28
+
29
+ def self.run_all
30
+ PeriodicalMailing.all.each {|m| m.run}
31
+ Sequence.all.each {|m| m.run}
32
+ end
33
+
34
+ def self.simulate period
35
+ File.open("/tmp/maily_herlald_timetravel.lock", "w") {}
36
+ time = Time.now
37
+ end_time = time + period
38
+ while time < end_time
39
+ Timecop.freeze(time)
40
+ run_all
41
+ time = time + 1.day
42
+ end
43
+ Timecop.return
44
+ File.delete("/tmp/maily_herlald_timetravel.lock")
45
+ end
46
+
47
+ def self.job_enqueued?
48
+ Sidekiq::Queue.new.detect{|j| j.klass == "MailyHerald::Async" } ||
49
+ Sidekiq::Workers.new.detect{|w, msg| msg["payload"]["class"] == "MailyHerald::Async" } ||
50
+ Sidekiq::RetrySet.new.detect{|j| j.klass = "MailyHerald::Async" }
51
+ end
52
+ end
53
+ end