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
@@ -2,16 +2,16 @@ module MailyHerald
2
2
  class Mailer < ActionMailer::Base
3
3
  attr_reader :entity
4
4
 
5
- def generic entity, mailing
6
- destination = mailing.destination(entity)
7
- subject = mailing.render_subject(entity)
8
- content = mailing.render_template(entity)
5
+ def generic entity
6
+ destination = @maily_herald_mailing.destination(entity)
7
+ subject = @maily_herald_mailing.render_subject(entity)
8
+ content = @maily_herald_mailing.render_template(entity)
9
9
 
10
10
  opts = {
11
11
  to: destination,
12
12
  subject: subject
13
13
  }
14
- opts[:from] = mailing.from if mailing.from.present?
14
+ opts[:from] = @maily_herald_mailing.from if @maily_herald_mailing.from.present?
15
15
 
16
16
  mail(opts) do |format|
17
17
  format.text { render text: content }
@@ -21,12 +21,22 @@ module MailyHerald
21
21
  class << self
22
22
  #TODO make it instance method so we get access to instance attributes
23
23
  def deliver_mail(mail) #:nodoc:
24
+ unless mail.maily_herald_data
25
+ MailyHerald.logger.error("Unable to send message. Invalid mailing provided.")
26
+ return
27
+ end
28
+
24
29
  mailing = mail.maily_herald_data[:mailing]
25
30
  entity = mail.maily_herald_data[:entity]
31
+ schedule = mail.maily_herald_data[:schedule]
26
32
 
33
+ if !schedule && mailing.respond_to?(:schedule_delivery_to)
34
+ # Implicitly create schedule for ad hoc delivery when called using Mailer.foo(entity).deliver syntax
35
+ schedule = mail.maily_herald_data[:schedule] = mailing.schedule_delivery_to(entity)
36
+ end
27
37
 
28
- if mailing && entity
29
- mailing.deliver_with_mailer_to(entity) do
38
+ if schedule
39
+ mailing.send(:deliver_with_mailer, schedule) do
30
40
  ActiveSupport::Notifications.instrument("deliver.action_mailer") do |payload|
31
41
  self.set_payload_for_mail(payload, mail)
32
42
  yield # Let Mail do the delivery actions
@@ -34,12 +44,7 @@ module MailyHerald
34
44
  mail
35
45
  end
36
46
  else
37
- MailyHerald.logger.log_processing(mailing, entity, mail, prefix: "Delivery outside Maily")
38
-
39
- ActiveSupport::Notifications.instrument("deliver.action_mailer") do |payload|
40
- self.set_payload_for_mail(payload, mail)
41
- yield # Let Mail do the delivery actions
42
- end
47
+ MailyHerald.logger.log_processing(mailing, entity, mail, prefix: "Attempt to deliver email without schedule. No mail was sent", level: :debug)
43
48
  end
44
49
  end
45
50
  end
@@ -48,9 +53,11 @@ module MailyHerald
48
53
  return @_message if @_mail_was_called && headers.blank? && !block
49
54
 
50
55
  # Assign instance variables availabe for template
51
- @maily_subscription = @_message.maily_herald_data[:subscription]
52
- @maily_entity = @_message.maily_herald_data[:entity]
53
- @maily_mailing = @_message.maily_herald_data[:mailing]
56
+ if @_message.maily_herald_data
57
+ @maily_subscription = @_message.maily_herald_data[:subscription]
58
+ @maily_entity = @_message.maily_herald_data[:entity]
59
+ @maily_mailing = @_message.maily_herald_data[:mailing]
60
+ end
54
61
 
55
62
  super
56
63
  end
@@ -70,20 +77,31 @@ module MailyHerald
70
77
  end
71
78
  end
72
79
 
73
- mailing = args[0].to_s == "generic" ? args[2] : MailyHerald.dispatch(args[0])
74
- entity = args[1]
80
+ if args[1].is_a?(MailyHerald::Log)
81
+ @maily_herald_schedule = args[1]
82
+ @maily_herald_mailing = @maily_herald_schedule.mailing
83
+ @maily_herald_entity = @maily_herald_schedule.entity
84
+ else
85
+ @maily_herald_mailing = args[0].to_s == "generic" ? args[2] : MailyHerald.dispatch(args[0])
86
+ @maily_herald_entity = args[1]
87
+ end
75
88
 
76
- @_message.maily_herald_data = {
77
- mailing: mailing,
78
- entity: entity,
79
- subscription: mailing.subscription_for(entity),
80
- }
89
+ if @maily_herald_mailing
90
+ @_message.maily_herald_data = {
91
+ schedule: @maily_herald_schedule,
92
+ mailing: @maily_herald_mailing,
93
+ entity: @maily_herald_entity,
94
+ subscription: @maily_herald_mailing.subscription_for(@maily_herald_entity),
95
+ }
96
+ end
81
97
 
82
98
  lookup_context.skip_default_locale!
83
- super
99
+ super(args[0], @maily_herald_entity)
84
100
 
85
- @_message.to = mailing.destination(entity) unless @_message.to
86
- @_message.from = mailing.from unless @_message.from
101
+ if @maily_herald_mailing
102
+ @_message.to = @maily_herald_mailing.destination(@maily_herald_entity) unless @_message.to
103
+ @_message.from = @maily_herald_mailing.from unless @_message.from
104
+ end
87
105
 
88
106
  @_message
89
107
  end
@@ -0,0 +1,109 @@
1
+ module MailyHerald
2
+ class AdHocMailing < Mailing
3
+ validates :list, presence: true
4
+
5
+ # Schedules mailing delivery to all entities in the scope at given `time`.
6
+ #
7
+ # This always creates new {MailyHerald::Log} objects of type `schedule`.
8
+ #
9
+ # @param time [Time] time of delivery
10
+ def schedule_delivery_to_all time = Time.now
11
+ self.list.context.scope_with_subscription(self.list, :outer).each do |entity|
12
+ MailyHerald.logger.debug "Adding schedule of #{self} ad-hoc for entity ##{entity.id} #{entity}"
13
+ schedule_delivery_to entity, time
14
+ end
15
+ end
16
+
17
+ # Schedules mailing delivery to `entity` at given `time`.
18
+ #
19
+ # This always creates new {MailyHerald::Log} object of type `schedule`.
20
+ #
21
+ # @param entity [ActiveRecord::Base]
22
+ # @param time [Time] time of delivery
23
+ def schedule_delivery_to entity, time = Time.now
24
+ subscribed = self.list.subscribed?(entity)
25
+
26
+ if !enabled? || !(self.override_subscription? || subscribed)
27
+ return
28
+ end
29
+
30
+ log = Log.new
31
+ log.with_lock do
32
+ log.set_attributes_for(self, entity, {
33
+ status: :scheduled,
34
+ processing_at: time,
35
+ })
36
+ log.save!
37
+ end
38
+ log
39
+ end
40
+
41
+ # Sends mailing to all subscribed entities who have delivery scheduled.
42
+ #
43
+ # Performs actual sending of emails; should be called in background.
44
+ #
45
+ # Returns array of {MailyHerald::Log} with actual `Mail::Message` objects stored
46
+ # in {MailyHerald::Log.mail} attributes.
47
+ def run
48
+ # TODO better scope here to exclude schedules for users outside context scope
49
+ schedules.where("processing_at <= (?)", Time.now).collect do |schedule|
50
+ if schedule.entity
51
+ mail = deliver schedule
52
+ schedule.reload
53
+ schedule.mail = mail
54
+ schedule
55
+ else
56
+ MailyHerald.logger.log_processing(schedule.mailing, {class: schedule.entity_type, id: schedule.entity_id}, prefix: "Removing schedule for non-existing entity")
57
+ schedule.destroy
58
+ end
59
+ end
60
+ end
61
+
62
+ # Sets the delivery schedule for given entity
63
+ #
64
+ # New schedule will be created or existing one updated.
65
+ #
66
+ # Schedule is {Log} object of type "schedule".
67
+ def set_schedule_for entity
68
+ subscribed = self.list.subscribed?(entity)
69
+
70
+ if !enabled? || !(self.override_subscription? || subscribed)
71
+ log = schedule_for(entity)
72
+ log.try(:destroy)
73
+ return
74
+ end
75
+ end
76
+
77
+ # Returns {Log} object which is the delivery schedule for given entity.
78
+ def schedule_for entity
79
+ schedules.for_entity(entity).first
80
+ end
81
+
82
+ # Returns collection of all delivery schedules ({Log} collection).
83
+ def schedules
84
+ Log.ordered.scheduled.for_mailing(self)
85
+ end
86
+
87
+ def to_s
88
+ "<AdHocMailing: #{self.title || self.name}>"
89
+ end
90
+
91
+ private
92
+
93
+ def deliver_with_mailer schedule
94
+ current_time = Time.now
95
+
96
+ schedule.with_lock do
97
+ # make sure schedule hasn't been processed in the meantime
98
+ if schedule && schedule.processing_at <= current_time && schedule.scheduled?
99
+ schedule = super(schedule)
100
+ if schedule
101
+ schedule.processing_at = current_time if schedule.processed?
102
+ schedule.save!
103
+ end
104
+ end
105
+ end if schedule
106
+ end
107
+
108
+ end
109
+ end
@@ -1,11 +1,42 @@
1
1
  module MailyHerald
2
+ # Main dispatch class.
3
+ #
4
+ # Inherited by all {Mailing} classes.
5
+ # Each Dispatch instance need to have associated {List}.
6
+ # Dispatch can be in one of three states:
7
+ # - +enabled+
8
+ # - +disabled+
9
+ # - +archived+
10
+ #
11
+ # @attr [String] type Polymorphic type.
12
+ # @attr [Fixnum] sequence_id {Sequence} association id.
13
+ # @attr [Fixnum] list_id {List} association id.
14
+ # @attr [String] conditions Delivery conditions as Liquid expression.
15
+ # @attr [String] mailer_name {Mailer} class name.
16
+ # This refers to {Mailer} used by Dispatch while sending emails.
17
+ # @attr [String] name Dispatch name.
18
+ # @attr [String] title Dispatch title.
19
+ # @attr [String] from Sender email address.
20
+ # If not provided, action_mailer.default_options[:from} is used.
21
+ # Valid only for {Mailing}.
22
+ # @attr [String] state
23
+ # @attr [String] subject Email subject as Liquid template.
24
+ # Valid only for {Mailing}.
25
+ # @attr [String] template Email body template as Liquid template.
26
+ # Valid only for {Mailing}.
27
+ # @attr [String] absolute_delay Email delivery delay from beginning of sequence.
28
+ # Valid only for {SequenceMailing}.
29
+ # @attr [String] period Email delivery period.
30
+ # Valid only for {PeriodicalMailing}.
31
+ # @attr [String] override_subscription Defines whether email should be sent regardless of
32
+ # entity subscription state.
2
33
  class Dispatch < ActiveRecord::Base
3
34
  belongs_to :list, class_name: "MailyHerald::List"
4
35
 
5
36
  validates :list, presence: true
6
37
  validates :state, presence: true, inclusion: {in: [:enabled, :disabled, :archived]}
7
38
  validate do |dispatch|
8
- dispatch.errors.add(:base, "Can't change this dispatch because it is locked.") if dispatch.locked?
39
+ dispatch.errors.add(:base, "Can't change this dispatch because it is locked.") if dispatch.changes.present? && dispatch.locked?
9
40
  end
10
41
  before_destroy do |dispatch|
11
42
  if dispatch.locked?
@@ -21,10 +52,52 @@ module MailyHerald
21
52
  scope :archived, lambda { where(state: :archived) }
22
53
  scope :not_archived, lambda { where("state != (?)", :archived) }
23
54
 
24
- scope :sequence, lambda { where(type: Sequence) }
55
+ scope :ad_hoc_mailing, lambda { where(type: AdHocMailing) }
25
56
  scope :one_time_mailing, lambda { where(type: OneTimeMailing) }
26
57
  scope :periodical_mailing, lambda { where(type: PeriodicalMailing) }
58
+ scope :sequence, lambda { where(type: Sequence) }
59
+
60
+ before_validation do
61
+ if @start_at_proc
62
+ self.start_at = "proc"
63
+ end
64
+ end
65
+
66
+ after_save do
67
+ if @start_at_proc
68
+ MailyHerald.start_at_procs[self.id] = @start_at_proc
69
+ end
70
+ end
71
+
72
+ # Sets start_at value of {OneTimeMailing}, {PeriodicalMailing} and {SequenceMailing}.
73
+ #
74
+ # @param v String with Liquid expression or `Proc` that evaluates to `Time`.
75
+ def start_at= v
76
+ if v.respond_to? :call
77
+ @start_at_proc = v
78
+ else
79
+ super(v.is_a?(Time) ? v.to_s : v)
80
+ end
81
+ end
27
82
 
83
+ # Returns time as string with Liquid expression or Proc.
84
+ def start_at
85
+ @start_at_proc || MailyHerald.start_at_procs[self.id] || read_attribute(:start_at)
86
+ end
87
+
88
+ def has_start_at_proc?
89
+ !!(@start_at_proc || MailyHerald.start_at_procs[self.id])
90
+ end
91
+
92
+ def start_at_changed?
93
+ if has_start_at_proc?
94
+ @start_at_proc != MailyHerald.start_at_procs[self.id]
95
+ else
96
+ super
97
+ end
98
+ end
99
+
100
+ # Returns dispatch state as symbol
28
101
  def state
29
102
  read_attribute(:state).to_sym
30
103
  end
@@ -59,18 +132,38 @@ module MailyHerald
59
132
  write_attribute(:state, "archived")
60
133
  end
61
134
 
135
+ # Sets {List} associated with this dispatch
136
+ #
137
+ # @param l {List} name or {List} object
62
138
  def list= l
63
139
  l = MailyHerald::List.find_by_name(l.to_s) if l.is_a?(String) || l.is_a?(Symbol)
64
140
  super(l)
65
141
  end
66
142
 
143
+ def subscription_valid? entity
144
+ self.override_subscription? || self.list.subscribed?(entity)
145
+ end
146
+
147
+ def in_scope? entity
148
+ self.list.context.scope.exists?(entity)
149
+ end
150
+
151
+ # Checks if dispatch can be sent to given entity.
152
+ #
153
+ # Following checks are performed:
154
+ # - dispatch is enabled,
155
+ # - subscription is overriden or user is subscribed to dispatch list,
156
+ # - entity belongs to list {Context} scope.
157
+ #
158
+ # @param entity [ActiveRecord::Base] Recipient
67
159
  def processable? entity
68
- self.enabled? && (self.override_subscription? || self.list.subscribed?(entity)) && self.list.context.scope.exists?(entity)
160
+ self.enabled? && subscription_valid?(entity) && in_scope?(entity)
69
161
  end
70
162
 
163
+ # Check if dispatch is locked.
164
+ # @see MailyHerald.dispatch_locked?
71
165
  def locked?
72
166
  MailyHerald.dispatch_locked?(self.name)
73
167
  end
74
-
75
168
  end
76
169
  end
@@ -1,4 +1,13 @@
1
1
  module MailyHerald
2
+ # Represents subscriptions list.
3
+ #
4
+ # Entities can be subscribed to lists by creating {Subscription} object for them.
5
+ #
6
+ # List have {Context} assigned. Only entities from Context scope can be subscribed to list.
7
+ #
8
+ # @attr [String] name
9
+ # @attr [String] title
10
+ # @attr [String] context_name Name of the {Context} used by List.
2
11
  class List < ActiveRecord::Base
3
12
  include MailyHerald::Autonaming
4
13
 
@@ -9,6 +18,8 @@ module MailyHerald
9
18
  has_many :dispatches, class_name: "MailyHerald::Dispatch"
10
19
  has_many :subscriptions, class_name: "MailyHerald::Subscription"
11
20
 
21
+ validates :context, presence: true
22
+
12
23
  validate do |list|
13
24
  list.errors.add(:base, "Can't change this list because it is locked.") if list.locked?
14
25
  end
@@ -19,67 +30,85 @@ module MailyHerald
19
30
  end
20
31
  end
21
32
 
33
+ # Returns {Context} object associated with List.
22
34
  def context
23
35
  @context ||= MailyHerald.context self.context_name
24
36
  end
25
37
 
38
+ # Subscribes entity to List.
39
+ #
40
+ # @param entity [Object] Entity object. Need to be in the {Context} scope.
26
41
  def subscribe! entity
27
42
  s = subscription_for(entity)
28
43
  s ? s.activate! : s = create_subscription_for(entity, true)
29
44
  s
30
45
  end
31
46
 
47
+ # Unsubscribes entity from List.
48
+ #
49
+ # @param entity [Object] Entity object.
32
50
  def unsubscribe! entity
33
51
  s = subscription_for(entity)
34
52
  s ? s.deactivate! : s = create_subscription_for(entity, false)
35
53
  s
36
54
  end
37
55
 
56
+ # Checks whether entity is subscribed to List.
38
57
  def subscribed? entity
39
- subscription_for(entity).try(:active?)
58
+ s = Subscription.get_from(entity) || subscription_for(entity)
59
+ s.try(:active?)
40
60
  end
41
61
 
42
- # true if user has inactive subscription or never been subscribed
62
+ # Checks whether entity is not subscribed to List.
63
+ #
64
+ # True if user has inactive subscription or never been subscribed.
43
65
  def unsubscribed? entity
44
- s = subscription_for(entity)
66
+ s = Subscription.get_from(entity) || subscription_for(entity)
45
67
  s ? !s.active? : true
46
68
  end
47
69
 
48
- # true only if user was intentionally unsubscribed
70
+ # Checks whether entity has been removed from List.
71
+ #
72
+ # True only if user was intentionally unsubscribed.
49
73
  def opted_out? entity
50
74
  s = subscription_for(entity)
51
75
  s ? !s.active? : false
52
76
  end
53
77
 
78
+ # Returns subscription for given entity.
54
79
  def subscription_for entity
55
80
  self.subscriptions.for_entity(entity).first
56
81
  end
57
82
 
83
+ # Returns number of List subscribers.
58
84
  def active_subscription_count
59
85
  subscribers.count(:id)
60
86
  end
61
87
 
62
- # Returns entities within the context's scope with active subscription
88
+ # Returns entities within the context's scope with active subscription.
63
89
  def subscribers
64
90
  context.scope_with_subscription(self).where("#{Subscription.table_name}.active = (?)", true).where("#{Subscription.table_name}.list_id = (?)", self.id)
65
91
  end
66
92
 
67
- # Returns entities within the context's scope with inactive subscription
93
+ # Returns entities within the context's scope with inactive subscription.
68
94
  def opt_outs
69
95
  context.scope_with_subscription(self).where("#{Subscription.table_name}.active = (?)", false).where("#{Subscription.table_name}.list_id = (?)", self.id)
70
96
  end
71
97
 
72
- # Returns entities within the context's scope without subscription
98
+ # Returns entities within the context's scope without subscription.
73
99
  def potential_subscribers
74
100
  sq = context.scope_with_subscription(self, :outer).where("#{Subscription.table_name}.list_id = (?)", self.id).pluck("#{context.model.table_name}.id")
75
101
  context.scope.where(context.model.arel_table[:id].not_in(sq))
76
102
  end
77
103
 
104
+ # Returns {Log} objects collection related to this List.
78
105
  def logs
79
106
  #Log.for_mailings(self.dispatches.select("id"))
80
107
  Log.for_mailings(Dispatch.where("sequence_id IN (?) OR list_id = (?)", Sequence.where(list_id: self.id).select("id"), self.id).select("id"))
81
108
  end
82
109
 
110
+ # Check if List is locked.
111
+ # @see MailyHerald.list_locked?
83
112
  def locked?
84
113
  MailyHerald.list_locked?(self.name)
85
114
  end