maily_herald 0.8.0 → 0.9.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. checksums.yaml +8 -8
  2. data/.gitignore +1 -0
  3. data/.travis.yml +3 -0
  4. data/Gemfile +0 -2
  5. data/Gemfile.lock +5 -13
  6. data/README.md +186 -69
  7. data/app/mailers/maily_herald/mailer.rb +44 -26
  8. data/app/models/maily_herald/ad_hoc_mailing.rb +109 -0
  9. data/app/models/maily_herald/dispatch.rb +97 -4
  10. data/app/models/maily_herald/list.rb +36 -7
  11. data/app/models/maily_herald/log.rb +79 -0
  12. data/app/models/maily_herald/mailing.rb +149 -24
  13. data/app/models/maily_herald/one_time_mailing.rb +114 -9
  14. data/app/models/maily_herald/periodical_mailing.rb +76 -47
  15. data/app/models/maily_herald/sequence.rb +57 -18
  16. data/app/models/maily_herald/sequence_mailing.rb +29 -20
  17. data/app/models/maily_herald/subscription.rb +23 -2
  18. data/config/routes.rb +2 -2
  19. data/lib/maily_herald.rb +57 -18
  20. data/lib/maily_herald/autonaming.rb +8 -0
  21. data/lib/maily_herald/context.rb +6 -2
  22. data/lib/maily_herald/logging.rb +2 -0
  23. data/lib/maily_herald/manager.rb +15 -31
  24. data/lib/maily_herald/utils.rb +65 -24
  25. data/lib/maily_herald/version.rb +1 -1
  26. data/maily_herald.gemspec +1 -1
  27. data/spec/controllers/maily_herald/tokens_controller_spec.rb +13 -13
  28. data/spec/dummy/app/mailers/ad_hoc_mailer.rb +11 -0
  29. data/spec/dummy/app/mailers/custom_one_time_mailer.rb +11 -0
  30. data/spec/dummy/config/application.rb +1 -1
  31. data/spec/dummy/config/environments/test.rb +1 -1
  32. data/spec/dummy/config/initializers/maily_herald.rb +3 -69
  33. data/spec/dummy/config/maily_herald.yml +3 -0
  34. data/spec/dummy/db/seeds.rb +73 -0
  35. data/spec/lib/context_spec.rb +7 -7
  36. data/spec/lib/maily_herald_spec.rb +7 -8
  37. data/spec/lib/utils_spec.rb +65 -25
  38. data/spec/mailers/maily_herald/mailer_spec.rb +20 -13
  39. data/spec/models/maily_herald/ad_hoc_mailing_spec.rb +169 -0
  40. data/spec/models/maily_herald/list_spec.rb +2 -1
  41. data/spec/models/maily_herald/log_spec.rb +10 -10
  42. data/spec/models/maily_herald/mailing_spec.rb +9 -8
  43. data/spec/models/maily_herald/one_time_mailing_spec.rb +212 -39
  44. data/spec/models/maily_herald/periodical_mailing_spec.rb +158 -92
  45. data/spec/models/maily_herald/sequence_mailing_spec.rb +2 -2
  46. data/spec/models/maily_herald/sequence_spec.rb +152 -139
  47. data/spec/models/maily_herald/subscription_spec.rb +21 -4
  48. metadata +17 -8
  49. data/lib/maily_herald/condition_evaluator.rb +0 -82
  50. data/lib/maily_herald/config.rb +0 -5
  51. data/spec/dummy/app/mailers/test_mailer.rb +0 -11
@@ -25,8 +25,23 @@ module MailyHerald
25
25
 
26
26
  after_save :update_schedules, if: Proc.new{|s| s.active_changed?}
27
27
 
28
+ def self.get_from(entity)
29
+ if entity.has_attribute?(:maily_subscription_id) && entity.maily_subscription_id
30
+ subscription = MailyHerald::Subscription.new
31
+
32
+ entity.attributes.each do |k, v|
33
+ if match = k.match(/^maily_subscription_(\w+)$/)
34
+ subscription.send("#{match[1]}=", v)
35
+ end
36
+ end
37
+
38
+ subscription.readonly!
39
+ subscription
40
+ end
41
+ end
42
+
28
43
  def active?
29
- !new_record? && read_attribute(:active)
44
+ read_attribute(:id) && read_attribute(:active)
30
45
  end
31
46
 
32
47
  def deactivate!
@@ -42,7 +57,7 @@ module MailyHerald
42
57
  end
43
58
 
44
59
  def token_url
45
- MailyHerald::Engine.routes.url_helpers.ubsubscribe_url(self)
60
+ MailyHerald::Engine.routes.url_helpers.maily_unsubscribe_url(self)
46
61
  end
47
62
 
48
63
  def to_liquid
@@ -52,6 +67,12 @@ module MailyHerald
52
67
  end
53
68
 
54
69
  def update_schedules
70
+ AdHocMailing.where(list_id: self.list).each do |m|
71
+ m.set_schedule_for self.entity
72
+ end
73
+ OneTimeMailing.where(list_id: self.list).each do |m|
74
+ m.set_schedule_for self.entity
75
+ end
55
76
  PeriodicalMailing.where(list_id: self.list).each do |m|
56
77
  m.set_schedule_for self.entity
57
78
  end
data/config/routes.rb CHANGED
@@ -3,9 +3,9 @@ MailyHerald::Engine.routes.draw do
3
3
  end
4
4
 
5
5
  MailyHerald::Engine.routes.url_helpers.class.module_eval do
6
- def unsubscribe_url(subscription, *args)
6
+ def maily_unsubscribe_url(subscription, *args)
7
7
  options = args.extract_options! || {}
8
- options = options.reverse_merge(controller: "/maily_herald/tokens", action: "get", token: subscription.token)
8
+ options = options.reverse_merge({controller: "/maily_herald/tokens", action: "get", token: subscription.token}.merge(Rails.application.routes.default_url_options))
9
9
 
10
10
  MailyHerald::Engine.routes.url_helpers.url_for(options)
11
11
  end
data/lib/maily_herald.rb CHANGED
@@ -19,14 +19,10 @@ module MailyHerald
19
19
  MailyHerald::Logging.initialize(logger_opts)
20
20
  end
21
21
 
22
- if args["mailing"] && args["entity"]
23
- MailyHerald::Manager.deliver args["mailing"], args["entity"]
24
- elsif args["mailing"]
22
+ if args["mailing"]
25
23
  MailyHerald::Manager.run_mailing args["mailing"]
26
24
  elsif args["sequence"]
27
25
  MailyHerald::Manager.run_sequence args["sequence"]
28
- elsif args["simulate"]
29
- MailyHerald::Manager.simulate args["simulate"]
30
26
  else
31
27
  MailyHerald::Manager.run_all
32
28
  end
@@ -38,17 +34,30 @@ module MailyHerald
38
34
 
39
35
  def perform id
40
36
  dispatch = MailyHerald::Dispatch.find(id)
41
- dispatch.update_schedules if dispatch.respond_to?(:update_schedules)
37
+ dispatch.set_schedules if dispatch.respond_to?(:set_schedules)
38
+ end
39
+ end
40
+
41
+ class Initializer
42
+ def initialize klass
43
+ @klass = klass
44
+ end
45
+
46
+ def method_missing m, *args, &block
47
+ if %w{list ad_hoc_mailing one_time_mailing periodical_mailing sequence_mailing sequence}.include?(m.to_s)
48
+ options = args.extract_options!
49
+ @klass.send m, *args, options.merge(locked: true), &block
50
+ else
51
+ @klass.send m, *args, &block
52
+ end
42
53
  end
43
54
  end
44
55
 
45
56
  autoload :Utils, 'maily_herald/utils'
46
- autoload :ConditionEvaluator, 'maily_herald/condition_evaluator'
47
57
  autoload :TemplateRenderer, 'maily_herald/template_renderer'
48
58
  autoload :ModelExtensions, 'maily_herald/model_extensions'
49
59
  autoload :Context, 'maily_herald/context'
50
60
  autoload :Manager, 'maily_herald/manager'
51
- autoload :Config, 'maily_herald/config'
52
61
  autoload :Autonaming, 'maily_herald/autonaming'
53
62
  autoload :Logging, 'maily_herald/logging'
54
63
 
@@ -105,6 +114,14 @@ module MailyHerald
105
114
  self.locked_lists.include?(name.to_s)
106
115
  end
107
116
 
117
+ def start_at_procs
118
+ @@start_at_procs ||= {}
119
+ end
120
+
121
+ def conditions_procs
122
+ @@conditions_procs ||= {}
123
+ end
124
+
108
125
  # Obtains Redis connection.
109
126
  def redis
110
127
  @redis ||= begin
@@ -139,11 +156,14 @@ module MailyHerald
139
156
  #
140
157
  # To be used in initializer file.
141
158
  def setup
142
- @@contexts ||= {}
159
+ logger.warn("Maily migrations seems to be pending. Skipping setup...") && return unless schema_loaded?
143
160
 
144
- logger.warn("Maily migrations seems to be pending. Skipping setup...") && return if ([MailyHerald::Dispatch, MailyHerald::List, MailyHerald::Log, MailyHerald::Subscription].collect(&:table_exists?).select{|v| !v}.length > 0)
161
+ yield Initializer.new(self)
162
+ end
145
163
 
146
- yield self
164
+ # Checks if Maily tables are present.
165
+ def schema_loaded?
166
+ !([MailyHerald::Dispatch, MailyHerald::List, MailyHerald::Log, MailyHerald::Subscription].collect(&:table_exists?).select{|v| !v}.length > 0)
147
167
  end
148
168
 
149
169
  # Fetches or defines a {Context}.
@@ -156,6 +176,7 @@ module MailyHerald
156
176
  # @param name [Symbol] Identifier name of the Context.
157
177
  def context name, &block
158
178
  name = name.to_s
179
+ @@contexts ||= {}
159
180
 
160
181
  if block_given?
161
182
  @@contexts ||= {}
@@ -175,6 +196,30 @@ module MailyHerald
175
196
  MailyHerald::Dispatch.find_by_name(name)
176
197
  end
177
198
 
199
+ # Fetches or defines an {AdHocMailing}.
200
+ #
201
+ # If no block provided, {AdHocMailing} with given +name+ is returned.
202
+ #
203
+ # If block provided, {AdHocMailing} with given +name+ is created or edited
204
+ # and block is evaluated within that mailing.
205
+ #
206
+ # @option options [true, false] :locked (false) Determines whether Mailing is locked.
207
+ # @see Dispatch#locked?
208
+ def ad_hoc_mailing name, options = {}
209
+ mailing = MailyHerald::AdHocMailing.where(name: name).first
210
+ lock = options.delete(:locked)
211
+
212
+ if block_given? && !self.dispatch_locked?(name) && (!mailing || lock)
213
+ mailing ||= MailyHerald::AdHocMailing.new(name: name)
214
+ yield(mailing)
215
+ mailing.save!
216
+
217
+ MailyHerald.lock_dispatch(name) if lock
218
+ end
219
+
220
+ mailing
221
+ end
222
+
178
223
  # Fetches or defines an {OneTimeMailing}.
179
224
  #
180
225
  # If no block provided, {OneTimeMailing} with given +name+ is returned.
@@ -311,13 +356,6 @@ module MailyHerald
311
356
  end
312
357
  end
313
358
 
314
- def deliver mailing_name, entity_id
315
- mailing_name = mailing_name.name if mailing_name.is_a?(Mailing)
316
- entity_id = entity_id.id if !entity_id.is_a?(Fixnum)
317
-
318
- Async.perform_async mailing: mailing_name, entity: entity_id, logger: MailyHerald::Logging.safe_options
319
- end
320
-
321
359
  def run_sequence seq_name
322
360
  seq_name = seq_name.name if seq_name.is_a?(Sequence)
323
361
 
@@ -342,6 +380,7 @@ module MailyHerald
342
380
  # Read options from config file
343
381
  def read_options cfile = "config/maily_herald.yml"
344
382
  opts = {}
383
+ cfile = Pathname.new(cfile).relative? && defined?(Rails) ? Rails.root + cfile : cfile
345
384
  if File.exist?(cfile)
346
385
  opts = YAML.load(ERB.new(IO.read(cfile)).result)
347
386
  end
@@ -1,4 +1,12 @@
1
1
  module MailyHerald
2
+
3
+ # Provides some common patters for models that have both :name and :title attributes.
4
+ # It adds some format constraints to :name and title attributes and makes sure
5
+ # that they are always both set properly.
6
+ #
7
+ # If only :name is provided, it will be used also as a:title.
8
+ # If only :title is provided, :name will be automatically generated out of it.
9
+ #
2
10
  module Autonaming
3
11
  def self.included(base)
4
12
  base.extend ClassMethods
@@ -103,9 +103,9 @@ module MailyHerald
103
103
  # Defines or returns Entity scope - collection of Entities.
104
104
  #
105
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.
106
+ # +ActiveRecord::Relation+ containing entity objects that will belong to scope.
107
107
  #
108
- # If no block given, scope proc is called and Entity collection returned.
108
+ # If no block given, scope proc is called and entity collection returned.
109
109
  def scope &block
110
110
  if block_given?
111
111
  @scope = block
@@ -210,6 +210,8 @@ module MailyHerald
210
210
  # Obtains {Context} attributes in a form of (nested) +Hash+ which
211
211
  # values are procs each returning single Entity attribute value.
212
212
  def attributes_list
213
+ return {} unless @attributes
214
+
213
215
  attributes = @attributes.dup
214
216
  attributes.setup
215
217
  attributes.for_drop
@@ -217,6 +219,8 @@ module MailyHerald
217
219
 
218
220
  # Returns Liquid drop created from Context attributes.
219
221
  def drop_for entity, subscription
222
+ return {} unless @attributes
223
+
220
224
  attributes = @attributes.dup
221
225
  attributes.setup entity, subscription
222
226
  Drop.new(attributes.for_drop)
@@ -50,6 +50,8 @@ module MailyHerald
50
50
  @options ||= OPTIONS.dup
51
51
  @options.merge!(opts) if opts
52
52
 
53
+ @options[:target] = Rails.root + @options[:target] if @options[:target].is_a?(String) && Pathname.new(@options[:target]).relative? && defined?(Rails)
54
+
53
55
  @logger = Logger.new(@options[:target])
54
56
  @logger.level = @options[:level]
55
57
  @logger.formatter = Formatter.new
@@ -1,49 +1,33 @@
1
1
  module MailyHerald
2
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)
3
+ # Run scheduled sequence mailing deliveries.
4
+ #
5
+ # @param sequence [Sequence, String, Symbol] {Sequence} object or name
6
+ def self.run_sequence sequence
7
+ seqence = Sequence.find_by_name(seqence) unless seqence.is_a?(Sequence)
13
8
 
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
9
+ sequence.run if sequence
21
10
  end
22
11
 
12
+ # Run scheduled periodical mailing deliveres.
13
+ #
14
+ # @param mailing [PeriodicalMailing, OneTimeMailing, String, Symbol]
15
+ # {AdHocMailing}, {OneTimeMailing} or {PeriodicalMailing} object or name
23
16
  def self.run_mailing mailing
24
- mailing = Mailing.find_by_name(mailing) if !mailing.is_a?(Mailing)
17
+ mailing = Mailing.find_by_name(mailing) unless mailing.is_a?(Mailing)
25
18
 
26
19
  mailing.run if mailing
27
20
  end
28
21
 
22
+ # Run all scheduled mailing deliveres.
29
23
  def self.run_all
24
+ AdHocMailing.all.each {|m| m.run}
25
+ OneTimeMailing.all.each {|m| m.run}
30
26
  PeriodicalMailing.all.each {|m| m.run}
31
27
  Sequence.all.each {|m| m.run}
32
28
  end
33
29
 
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
-
30
+ # Check if Maily sidekiq job is running.
47
31
  def self.job_enqueued?
48
32
  Sidekiq::Queue.new.detect{|j| j.klass == "MailyHerald::Async" } ||
49
33
  Sidekiq::Workers.new.detect{|w, msg| msg["payload"]["class"] == "MailyHerald::Async" } ||
@@ -5,32 +5,61 @@ module MailyHerald
5
5
  end
6
6
 
7
7
  class MarkupEvaluator
8
- class DummyDrop < Liquid::Drop
9
- def has_key?(name)
10
- true
11
- end
8
+ VariableSignature = /\A[\w\.\[\]]+(\s*|\s*.+)?\Z/
12
9
 
13
- def invoke_drop name
14
- true
15
- end
10
+ module Filters
11
+ module Date
12
+ def minus input, no, unit
13
+ input - no.to_i.send(unit)
14
+ end
16
15
 
17
- alias :[] :invoke_drop
16
+ def plus input, no, unit
17
+ input + no.to_i.send(unit)
18
+ end
19
+ end
18
20
  end
19
21
 
20
22
  def self.test_conditions conditions
21
23
  return true if !conditions || conditions.empty?
22
24
 
23
- condition = self.create_liquid_condition conditions
24
- template = Liquid::Template.parse(conditions)
25
- raise StandardError unless template.errors.empty?
26
-
27
- drop = DummyDrop.new
28
- liquid_context = Liquid::Context.new([drop, template.assigns], template.instance_assigns, template.registers, true, {})
29
- drop.context = liquid_context
30
-
31
- condition.evaluate liquid_context
25
+ drop = Class.new(Liquid::Drop) do
26
+ def has_key?(name); true; end
27
+ def invoke_drop(name); true; end
28
+ alias :[] :invoke_drop
29
+ end.new
30
+
31
+ evaluator = Utils::MarkupEvaluator.new(drop)
32
+ evaluator.evaluate_conditions(conditions)
33
+ true
34
+ rescue
35
+ return false
32
36
  end
33
37
 
38
+ def self.test_start_at markup
39
+ return true if !markup || markup.empty?
40
+
41
+ drop = Class.new(Liquid::Drop) do
42
+ def has_key?(name); true; end
43
+ def invoke_drop(name)
44
+ t = Time.now
45
+ t.define_singleton_method(:[]) do |v|
46
+ Time.now
47
+ end
48
+ t.define_singleton_method(:has_key?) do |v|
49
+ true
50
+ end
51
+ t
52
+ end
53
+ alias :[] :invoke_drop
54
+ end.new
55
+
56
+ evaluator = Utils::MarkupEvaluator.new(drop)
57
+ val = evaluator.evaluate_start_at(markup)
58
+
59
+ return val.is_a?(Time) || val.is_a?(DateTime)
60
+ rescue
61
+ return false
62
+ end
34
63
 
35
64
  def initialize drop
36
65
  @drop = drop
@@ -41,19 +70,31 @@ module MailyHerald
41
70
 
42
71
  condition = MarkupEvaluator.create_liquid_condition conditions
43
72
  template = Liquid::Template.parse(conditions)
73
+ raise StandardError unless template.errors.empty?
44
74
 
45
75
  liquid_context = Liquid::Context.new([@drop, template.assigns], template.instance_assigns, template.registers, true, {})
46
- @drop.context = liquid_context
76
+ @drop.context = liquid_context if @drop.is_a?(Liquid::Drop)
47
77
 
48
- condition.evaluate liquid_context
78
+ val = condition.evaluate liquid_context
79
+ raise(ArgumentError, "Conditions do not evaluate to boolean (got `#{val}`)") unless [true, false].include?(val)
80
+ val
49
81
  end
50
82
 
51
- def evaluate_variable markup
52
- template = Liquid::Template.parse(markup)
83
+ def evaluate_start_at markup
84
+ begin
85
+ Time.parse(markup)
86
+ rescue
87
+ raise(ArgumentError, "Start at is not a proper variable: `#{markup}`") unless VariableSignature =~ markup
53
88
 
54
- liquid_context = Liquid::Context.new([@drop, template.assigns], template.instance_assigns, template.registers, true, {})
55
- @drop.context = liquid_context
56
- liquid_context[markup]
89
+ liquid_context = Liquid::Context.new([@drop], {}, {}, true, {})
90
+ liquid_context.add_filters([Filters::Date])
91
+
92
+ @drop.context = liquid_context if @drop.is_a?(Liquid::Drop)
93
+ #liquid_context[markup]
94
+
95
+ variable = Liquid::Variable.new markup
96
+ variable.render(liquid_context)
97
+ end
57
98
  end
58
99
 
59
100
  private
@@ -1,3 +1,3 @@
1
1
  module MailyHerald
2
- VERSION = "0.8.0"
2
+ VERSION = "0.9.1"
3
3
  end
data/maily_herald.gemspec CHANGED
@@ -13,7 +13,7 @@ Gem::Specification.new do |s|
13
13
  s.email = ["lukasz@sology.eu"]
14
14
  s.homepage = "https://github.com/Sology/maily_herald"
15
15
  s.license = "LGPL-3.0"
16
- s.description = s.summary = "Email marketing solution for Ruby on Rails applications"
16
+ s.description = s.summary = "Email processing solution for Ruby on Rails applications"
17
17
 
18
18
  s.files = `git ls-files`.split("\n")
19
19
  s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }