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
@@ -1,4 +1,21 @@
1
1
  module MailyHerald
2
+ # Stores information about email delivery to entity.
3
+ #
4
+ # It is associated with entity object and {Dispatch}.
5
+ # Log can have following statuses:
6
+ # - +scheduled+ - email hasn't been processed yet,
7
+ # - +delivered+ - email was sent to entity,
8
+ # - +skipped+ - email deliver was skipped (i.e. due to conditions not met),
9
+ # - +error+ - there was an error during email delivery.
10
+ #
11
+ # @attr [Fixnum] entity_id Entity association id.
12
+ # @attr [String] entity_type Entity association type.
13
+ # @attr [String] entity_email Delivery email. Stored in case associated entity gets deleted.
14
+ # @attr [Fixnum] mailing_id {Dispatch} association id.
15
+ # @attr [Sumbol] status
16
+ # @attr [Hash] data Custom log data.
17
+ # @attr [DateTime] processing_at Timestamp of {Dispatch} processing.
18
+ # Can be either future (when in +scheduled+ state) or past.
2
19
  class Log < ActiveRecord::Base
3
20
  AVAILABLE_STATUSES = [:scheduled, :delivered, :skipped, :error]
4
21
 
@@ -20,13 +37,29 @@ module MailyHerald
20
37
  scope :error, lambda { where(status: :error) }
21
38
  scope :scheduled, lambda { where(status: :scheduled) }
22
39
  scope :processed, lambda { where(status: [:delivered, :skipped, :error]) }
40
+ scope :not_skipped, lambda { where("status != 'skipped'") }
41
+ scope :like_email, lambda {|query| where("maily_herald_logs.entity_email LIKE (?)", "%#{query}%") }
23
42
 
24
43
  serialize :data, Hash
25
44
 
45
+ # Contains `Mail::Message` object that was delivered.
46
+ #
47
+ # Present only in logs of state `delivered` and obtained via
48
+ # `Mailing.run` method.
49
+ attr_accessor :mail
50
+
26
51
  if Rails::VERSION::MAJOR == 3
27
52
  attr_accessible :status, :data
28
53
  end
29
54
 
55
+ # Creates Log object for given {Dispatch} and entity.
56
+ #
57
+ # @param mailing [Dispatch]
58
+ # @param entity [ActiveRecord::Base]
59
+ # @param attributes [Hash] log attributes
60
+ # @option attributes [Time] :processing_at (DateTime.now)
61
+ # @option attributes [Symbol] :status
62
+ # @option attributes [Hash] :data
30
63
  def self.create_for mailing, entity, attributes = {}
31
64
  log = Log.new
32
65
  log.set_attributes_for mailing, entity, attributes
@@ -34,6 +67,14 @@ module MailyHerald
34
67
  log
35
68
  end
36
69
 
70
+ # Sets Log instance attributes.
71
+ #
72
+ # @param mailing [Dispatch]
73
+ # @param entity [ActiveRecord::Base]
74
+ # @param attributes [Hash] log attributes
75
+ # @option attributes [Time] :processing_at (DateTime.now)
76
+ # @option attributes [Symbol] :status
77
+ # @option attributes [Hash] :data
37
78
  def set_attributes_for mailing, entity, attributes = {}
38
79
  self.mailing = mailing
39
80
  self.entity = entity
@@ -63,5 +104,43 @@ module MailyHerald
63
104
  def scheduled?
64
105
  self.status == :scheduled
65
106
  end
107
+
108
+ def processed?
109
+ [:delivered, :skipped, :error].include?(self.status)
110
+ end
111
+
112
+ # Set attributes of a schedule so it has 'skipped' status.
113
+ def skip reason
114
+ if self.status == :scheduled
115
+ self.status = :skipped
116
+ self.data[:skip_reason] = reason
117
+ true
118
+ end
119
+ end
120
+
121
+ # Set attributes of a schedule so it is postponed.
122
+ def postpone_delivery
123
+ if !self.data[:delivery_attempts] || self.data[:delivery_attempts].length < 3
124
+ self.data[:original_processing_at] ||= self.processing_at
125
+ self.data[:delivery_attempts] ||= []
126
+ self.data[:delivery_attempts].push(date_at: Time.now, action: :postpone, reason: :not_processable)
127
+ self.processing_at = Time.now + 1.day
128
+ true
129
+ end
130
+ end
131
+
132
+ # Set attributes of a schedule so it has 'delivered' status.
133
+ # @param content Body of delivered email.
134
+ def deliver content
135
+ self.status = :delivered
136
+ self.data[:content] = content
137
+ end
138
+
139
+ # Set attributes of a schedule so it has 'error' status.
140
+ # @param msg Error description.
141
+ def error msg
142
+ self.status = :error
143
+ self.data[:msg] = msg
144
+ end
66
145
  end
67
146
  end
@@ -12,11 +12,14 @@ module MailyHerald
12
12
 
13
13
  validates :subject, presence: true, if: :generic_mailer?
14
14
  validates :template, presence: true, if: :generic_mailer?
15
+ validate :mailer_validity
15
16
  validate :template_syntax
16
17
  validate :validate_conditions
17
18
 
18
19
  before_validation do
19
20
  write_attribute(:name, self.title.downcase.gsub(/\W/, "_")) if self.title && (!self.name || self.name.empty?)
21
+ write_attribute(:conditions, nil) if !self.has_conditions_proc? && self.conditions.try(:empty?)
22
+ write_attribute(:from, nil) if self.from.try(:empty?)
20
23
  end
21
24
 
22
25
  after_initialize do
@@ -24,6 +27,58 @@ module MailyHerald
24
27
  self.override_subscription = false
25
28
  self.mailer_name = :generic
26
29
  end
30
+
31
+ if @conditions_proc
32
+ self.conditions = "proc"
33
+ end
34
+ end
35
+
36
+ after_save do
37
+ if @conditions_proc
38
+ MailyHerald.conditions_procs[self.id] = @conditions_proc
39
+ end
40
+ end
41
+
42
+ # Sets mailing conditions.
43
+ #
44
+ # @param v String with Liquid expression or `Proc` that evaluates to `true` or `false`.
45
+ def conditions= v
46
+ if v.respond_to? :call
47
+ @conditions_proc = v
48
+ else
49
+ write_attribute(:conditions, v)
50
+ end
51
+ end
52
+
53
+ # Returns time as string with Liquid expression or Proc.
54
+ def conditions
55
+ @conditions_proc || MailyHerald.conditions_procs[self.id] || read_attribute(:conditions)
56
+ end
57
+
58
+ def has_conditions_proc?
59
+ @conditions_proc || MailyHerald.conditions_procs[self.id]
60
+ end
61
+
62
+ def conditions_changed?
63
+ if has_conditions_proc?
64
+ @conditions_proc != MailyHerald.conditions_procs[self.id]
65
+ else
66
+ super
67
+ end
68
+ end
69
+
70
+ def general_scheduling?
71
+ self.start_at.is_a?(String) && Time.parse(self.start_at).is_a?(Time)
72
+ rescue
73
+ false
74
+ end
75
+
76
+ def individual_scheduling?
77
+ !general_scheduling?
78
+ end
79
+
80
+ def ad_hoc?
81
+ self.class == AdHocMailing
27
82
  end
28
83
 
29
84
  def periodical?
@@ -42,6 +97,7 @@ module MailyHerald
42
97
  read_attribute(:mailer_name).to_sym
43
98
  end
44
99
 
100
+ # Returns {Mailer} class used by this Mailing.
45
101
  def mailer
46
102
  if generic_mailer?
47
103
  MailyHerald::Mailer
@@ -50,29 +106,51 @@ module MailyHerald
50
106
  end
51
107
  end
52
108
 
53
- def has_conditions?
54
- self.conditions && !self.conditions.empty?
55
- end
56
-
109
+ # Checks whether Mailing uses generic mailer.
57
110
  def generic_mailer?
58
111
  self.mailer_name == :generic
59
112
  end
60
113
 
114
+ # Checks whether Mailig has conditions defined.
115
+ def has_conditions?
116
+ self.conditions && (has_conditions_proc? || !self.conditions.empty?)
117
+ end
118
+
119
+ # Checks whether entity meets conditions of this Mailing.
120
+ #
121
+ # @raise [ArgumentError] if the conditions do not evaluate to boolean.
61
122
  def conditions_met? entity
62
- subscription = self.list.subscription_for(entity)
123
+ subscription = Subscription.get_from(entity) || self.list.subscription_for(entity)
63
124
 
64
- if self.list.context.attributes
65
- evaluator = Utils::MarkupEvaluator.new(self.list.context.drop_for(entity, subscription))
66
- evaluator.evaluate_conditions(self.conditions)
125
+ if has_conditions_proc?
126
+ !!conditions.call(entity, subscription)
67
127
  else
68
- true
128
+ if self.list.context.attributes
129
+ evaluator = Utils::MarkupEvaluator.new(self.list.context.drop_for(entity, subscription))
130
+ evaluator.evaluate_conditions(self.conditions)
131
+ else
132
+ true
133
+ end
69
134
  end
70
135
  end
71
136
 
137
+ # Checks whether conditions evaluate properly for given entity.
138
+ def test_conditions entity
139
+ conditions_met?(entity)
140
+ true
141
+ rescue StandardError => e
142
+ false
143
+ end
144
+
145
+ # Returns destination email address for given entity.
72
146
  def destination entity
73
147
  self.list.context.destination_for(entity)
74
148
  end
75
149
 
150
+ # Renders email body for given entity.
151
+ #
152
+ # Reads {#template} attribute and renders it using Liquid within the context
153
+ # for provided entity.
76
154
  def render_template entity
77
155
  subscription = self.list.subscription_for(entity)
78
156
  return unless subscription
@@ -81,6 +159,10 @@ module MailyHerald
81
159
  perform_template_rendering drop, self.template
82
160
  end
83
161
 
162
+ # Renders email subject line for given entity.
163
+ #
164
+ # Reads {#subject} attribute and renders it using Liquid within the context
165
+ # for provided entity.
84
166
  def render_subject entity
85
167
  subscription = self.list.subscription_for(entity)
86
168
  return unless subscription
@@ -89,40 +171,63 @@ module MailyHerald
89
171
  perform_template_rendering drop, self.subject
90
172
  end
91
173
 
92
- def build_mail entity
174
+ # Builds `Mail::Message` object for given entity.
175
+ #
176
+ # Depending on {#mailer_name} value it uses either generic mailer (from {Mailer} class)
177
+ # or custom mailer.
178
+ def build_mail schedule
93
179
  if generic_mailer?
94
- Mailer.generic(entity, self)
180
+ Mailer.generic(schedule, self)
95
181
  else
96
- self.mailer.send(self.name, entity)
182
+ self.mailer.send(self.name, schedule)
97
183
  end
98
184
  end
99
185
 
100
- def deliver_to entity
101
- build_mail(entity).deliver
102
- end
103
-
104
186
  protected
105
187
 
188
+ # Sends mailing to given entity.
189
+ #
190
+ # Performs actual sending of emails; should be called in background.
191
+ #
192
+ # Returns `Mail::Message`.
193
+ def deliver schedule
194
+ build_mail(schedule).deliver
195
+ rescue StandardError => e
196
+ MailyHerald.logger.log_processing(self, schedule.entity, prefix: "Error", level: :error)
197
+ schedule.update_attributes(status: :error, data: {msg: "#{e.to_s}\n\n#{e.backtrace.join("\n")}"})
198
+ return nil
199
+ end
200
+
106
201
  # Called from Mailer, block required
107
- def deliver_with_mailer_to entity
202
+ def deliver_with_mailer schedule
203
+ entity = schedule.entity
204
+
108
205
  unless processable?(entity)
109
- MailyHerald.logger.log_processing(self, entity, prefix: "Not processable", level: :debug)
110
- return
206
+ # Most likely the entity went out of the context scope.
207
+ # Let's leave the log for now just in case it comes back into the scope.
208
+ MailyHerald.logger.log_processing(self, entity, prefix: "Not processable. Delaying schedule by one day", level: :debug)
209
+ skip_reason = in_scope?(entity) ? :not_processable : :not_in_scope
210
+ schedule.skip(skip_reason) unless schedule.postpone_delivery
211
+ return schedule
111
212
  end
112
213
 
113
214
  unless conditions_met?(entity)
114
215
  MailyHerald.logger.log_processing(self, entity, prefix: "Conditions not met", level: :debug)
115
- return {status: :skipped}
216
+ schedule.skip(:conditions_unmet)
217
+ return schedule
116
218
  end
117
219
 
118
220
  mail = yield # Let mailer do his job
119
221
 
120
222
  MailyHerald.logger.log_processing(self, entity, mail, prefix: "Processed")
223
+ schedule.deliver(mail.to_s)
121
224
 
122
- return {status: :delivered, data: {content: mail.to_s}}
225
+ return schedule
123
226
  rescue StandardError => e
124
- MailyHerald.logger.log_processing(self, entity, prefix: "Error", level: :error)
125
- return {status: :error, data: {msg: "#{e.to_s}\n\n#{e.backtrace.join("\n")}"}}
227
+ MailyHerald.logger.log_processing(self, schedule.entity, prefix: "Error", level: :error)
228
+ schedule.error("#{e.to_s}\n\n#{e.backtrace.join("\n")}")
229
+
230
+ return schedule
126
231
  end
127
232
 
128
233
  private
@@ -136,9 +241,29 @@ module MailyHerald
136
241
  end
137
242
 
138
243
  def validate_conditions
139
- evaluator = Utils::MarkupEvaluator.test_conditions(self.conditions)
244
+ return true if has_conditions_proc?
245
+
246
+ result = Utils::MarkupEvaluator.test_conditions(self.conditions)
247
+
248
+ errors.add(:conditions, "is not a boolean value") unless result
140
249
  rescue StandardError => e
141
250
  errors.add(:conditions, e.to_s)
142
251
  end
252
+
253
+ def validate_start_at
254
+ return true if has_start_at_proc?
255
+
256
+ result = Utils::MarkupEvaluator.test_start_at(self.start_at)
257
+
258
+ errors.add(:start_at, "is not a time value") unless result
259
+ rescue StandardError => e
260
+ errors.add(:start_at, e.to_s)
261
+ end
262
+
263
+ def mailer_validity
264
+ !!mailer unless generic_mailer?
265
+ rescue
266
+ errors.add(:mailer_name, :invalid)
267
+ end
143
268
  end
144
269
  end
@@ -1,26 +1,131 @@
1
1
  module MailyHerald
2
2
  class OneTimeMailing < Mailing
3
3
  validates :list, presence: true
4
+ validates :start_at, presence: true
5
+ validate :validate_start_at
4
6
 
5
- # Returns array of Mail::Message
7
+ after_save :update_schedules_callback, if: Proc.new{|m| m.state_changed? || m.start_at_changed? || m.override_subscription?}
8
+
9
+ # Sends mailing to all subscribed entities.
10
+ #
11
+ # Performs actual sending of emails; should be called in background.
12
+ #
13
+ # Returns array of {MailyHerald::Log} with actual `Mail::Message` objects stored
14
+ # in {MailyHerald::Log.mail} attributes.
6
15
  def run
7
- self.list.subscriptions.collect do |subscription|
8
- entity = subscription.entity
16
+ # TODO better scope here to exclude schedules for users outside context scope
17
+ schedules.where("processing_at <= (?)", Time.now).collect do |schedule|
18
+ if schedule.entity
19
+ mail = deliver schedule
20
+ schedule.reload
21
+ schedule.mail = mail
22
+ schedule
23
+ else
24
+ MailyHerald.logger.log_processing(schedule.mailing, {class: schedule.entity_type, id: schedule.entity_id}, prefix: "Removing schedule for non-existing entity")
25
+ schedule.destroy
26
+ end
27
+ end
28
+ end
29
+
30
+ # Returns collection of processed {Log}s for given entity.
31
+ def processed_logs entity
32
+ Log.ordered.for_entity(entity).for_mailing(self).processed
33
+ end
34
+
35
+ # Sets the delivery schedule for given entity
36
+ #
37
+ # New schedule will be created or existing one updated.
38
+ # Schedule is {Log} object of type "schedule".
39
+ def set_schedule_for entity
40
+ if processed_logs(entity).last
41
+ # this mailing is sent only once
42
+ log = schedule_for(entity)
43
+ log.try(:destroy)
44
+ return
45
+ end
9
46
 
10
- next unless processable?(entity)
47
+ subscribed = self.list.subscribed?(entity)
48
+ start_time = start_processing_time(entity)
11
49
 
12
- deliver_to entity
50
+ if !self.start_at || !enabled? || !start_time || !(self.override_subscription? || subscribed)
51
+ log = schedule_for(entity)
52
+ log.try(:destroy)
53
+ return
13
54
  end
55
+
56
+ log = schedule_for(entity)
57
+
58
+ log ||= Log.new
59
+ log.with_lock do
60
+ log.set_attributes_for(self, entity, {
61
+ status: :scheduled,
62
+ processing_at: start_time,
63
+ })
64
+ log.save!
65
+ end
66
+ log
14
67
  end
15
68
 
16
- # Returns single Mail::Message
17
- def deliver_with_mailer_to entity
18
- attrs = super entity
19
- Log.create_for(self, entity, attrs) if attrs
69
+ # Sets delivery schedules of all entities in mailing scope.
70
+ #
71
+ # New schedules will be created or existing ones updated.
72
+ def set_schedules
73
+ self.list.context.scope_with_subscription(self.list, :outer).each do |entity|
74
+ MailyHerald.logger.debug "Updating schedule of #{self} one-time for entity ##{entity.id} #{entity}"
75
+ set_schedule_for entity
76
+ end
77
+ end
78
+
79
+ # Returns {Log} object which is the delivery schedule for given entity.
80
+ def schedule_for entity
81
+ schedules.for_entity(entity).first
82
+ end
83
+
84
+ # Returns collection of all delivery schedules ({Log} collection).
85
+ def schedules
86
+ Log.ordered.scheduled.for_mailing(self)
87
+ end
88
+
89
+ # Returns processing time for given entity.
90
+ #
91
+ # This is the time when next mailing should be sent based on
92
+ # {#start_at} mailing attribute.
93
+ def start_processing_time entity
94
+ subscription = self.list.subscription_for(entity)
95
+
96
+ if has_start_at_proc?
97
+ start_at.call(entity, subscription)
98
+ else
99
+ evaluator = Utils::MarkupEvaluator.new(self.list.context.drop_for(entity, subscription))
100
+
101
+ evaluator.evaluate_start_at(self.start_at)
102
+ end
20
103
  end
21
104
 
22
105
  def to_s
23
106
  "<OneTimeMailing: #{self.title || self.name}>"
24
107
  end
108
+
109
+ private
110
+
111
+ def deliver_with_mailer schedule
112
+ current_time = Time.now
113
+
114
+ schedule.with_lock do
115
+ # make sure schedule hasn't been processed in the meantime
116
+ if schedule && schedule.processing_at <= current_time && schedule.scheduled?
117
+ schedule = super(schedule)
118
+ if schedule
119
+ schedule.processing_at = current_time if schedule.processed?
120
+ schedule.save!
121
+ end
122
+ end
123
+ end if schedule
124
+ end
125
+
126
+ def update_schedules_callback
127
+ Rails.env.test? ? set_schedules : MailyHerald::ScheduleUpdater.perform_in(10.seconds, self.id)
128
+ end
129
+
25
130
  end
26
131
  end