railsdrip-engine 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +6 -0
- data/LICENSE.txt +4 -0
- data/README.md +80 -0
- data/app/controllers/concerns/rails_drips/admin_access.rb +28 -0
- data/app/controllers/rails_drips/application_controller.rb +7 -0
- data/app/controllers/rails_drips/campaign_archivals_controller.rb +11 -0
- data/app/controllers/rails_drips/campaign_pauses_controller.rb +11 -0
- data/app/controllers/rails_drips/campaign_resumptions_controller.rb +11 -0
- data/app/controllers/rails_drips/campaigns_controller.rb +14 -0
- data/app/controllers/rails_drips/deliveries_controller.rb +14 -0
- data/app/controllers/rails_drips/delivery_cancellations_controller.rb +11 -0
- data/app/controllers/rails_drips/delivery_retries_controller.rb +11 -0
- data/app/controllers/rails_drips/enrollments_controller.rb +11 -0
- data/app/jobs/rails_drips/delivery_job.rb +89 -0
- data/app/models/concerns/rails_drips/json_data.rb +29 -0
- data/app/models/rails_drips/application_record.rb +8 -0
- data/app/models/rails_drips/campaign.rb +62 -0
- data/app/models/rails_drips/campaign_version.rb +35 -0
- data/app/models/rails_drips/delivery.rb +90 -0
- data/app/models/rails_drips/enrollment.rb +77 -0
- data/app/models/rails_drips/enrollment_event.rb +28 -0
- data/app/models/rails_drips/step.rb +53 -0
- data/app/services/rails_drips/activate_campaign.rb +10 -0
- data/app/services/rails_drips/archive_campaign.rb +22 -0
- data/app/services/rails_drips/cancel_delivery.rb +17 -0
- data/app/services/rails_drips/compare_definition.rb +28 -0
- data/app/services/rails_drips/complete_enrollment.rb +18 -0
- data/app/services/rails_drips/enqueue_due_deliveries.rb +16 -0
- data/app/services/rails_drips/enroll.rb +72 -0
- data/app/services/rails_drips/import_definition.rb +68 -0
- data/app/services/rails_drips/integrity_audit.rb +43 -0
- data/app/services/rails_drips/mail_snapshot.rb +31 -0
- data/app/services/rails_drips/pause_campaign.rb +16 -0
- data/app/services/rails_drips/policy_decision.rb +21 -0
- data/app/services/rails_drips/record_event.rb +14 -0
- data/app/services/rails_drips/remove.rb +27 -0
- data/app/services/rails_drips/resume_campaign.rb +17 -0
- data/app/services/rails_drips/retry_delivery.rb +21 -0
- data/app/services/rails_drips/schedule_deliveries.rb +23 -0
- data/app/services/rails_drips/unarchive_campaign.rb +14 -0
- data/app/views/layouts/rails_drips/application.html.erb +5 -0
- data/app/views/rails_drips/campaigns/index.html.erb +7 -0
- data/app/views/rails_drips/campaigns/show.html.erb +7 -0
- data/app/views/rails_drips/deliveries/show.html.erb +9 -0
- data/app/views/rails_drips/enrollments/show.html.erb +6 -0
- data/config/routes.rb +14 -0
- data/db/migrate/20260829123000_create_rails_drips_tables.rb +142 -0
- data/docs/README.md +13 -0
- data/docs/architecture.md +95 -0
- data/docs/campaign-definitions.md +100 -0
- data/docs/getting-started.md +170 -0
- data/docs/host-integration.md +160 -0
- data/docs/operations.md +104 -0
- data/lib/generators/rails_drips/install/install_generator.rb +32 -0
- data/lib/generators/rails_drips/install/templates/initializer.rb +8 -0
- data/lib/generators/rails_drips/install/templates/sample.yml +13 -0
- data/lib/rails_drips/configuration.rb +65 -0
- data/lib/rails_drips/definition_loader.rb +42 -0
- data/lib/rails_drips/definition_schema.rb +40 -0
- data/lib/rails_drips/engine.rb +9 -0
- data/lib/rails_drips/errors.rb +17 -0
- data/lib/rails_drips/instrumentation.rb +11 -0
- data/lib/rails_drips/mail_registry.rb +62 -0
- data/lib/rails_drips/queue_inventory.rb +10 -0
- data/lib/rails_drips/result.rb +9 -0
- data/lib/rails_drips/version.rb +5 -0
- data/lib/rails_drips.rb +65 -0
- data/lib/tasks/rails_drips_tasks.rake +28 -0
- metadata +179 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class Step < ApplicationRecord
|
|
5
|
+
include JsonData
|
|
6
|
+
|
|
7
|
+
DELAY_UNITS = %w[minutes hours days].freeze
|
|
8
|
+
|
|
9
|
+
belongs_to :campaign_version,
|
|
10
|
+
class_name: "RailsDrips::CampaignVersion",
|
|
11
|
+
inverse_of: :steps
|
|
12
|
+
has_many :deliveries,
|
|
13
|
+
class_name: "RailsDrips::Delivery",
|
|
14
|
+
dependent: :restrict_with_error,
|
|
15
|
+
inverse_of: :step
|
|
16
|
+
|
|
17
|
+
enum :kind, { email: 0 }, prefix: true
|
|
18
|
+
|
|
19
|
+
validates :position,
|
|
20
|
+
presence: true,
|
|
21
|
+
numericality: { only_integer: true, greater_than: 0 },
|
|
22
|
+
uniqueness: { scope: :campaign_version_id }
|
|
23
|
+
validates :name, :kind, :delay_unit, :email_key, presence: true
|
|
24
|
+
validates :delay_amount,
|
|
25
|
+
presence: true,
|
|
26
|
+
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
|
27
|
+
validates :delay_unit, inclusion: { in: DELAY_UNITS }
|
|
28
|
+
validate :email_key_registered
|
|
29
|
+
|
|
30
|
+
def resolved_email_category
|
|
31
|
+
email_category.presence || campaign_version.campaign.default_email_category
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def delay_duration
|
|
35
|
+
case delay_unit
|
|
36
|
+
when "minutes" then delay_amount.minutes
|
|
37
|
+
when "hours" then delay_amount.hours
|
|
38
|
+
when "days" then delay_amount.days
|
|
39
|
+
else
|
|
40
|
+
raise ArgumentError, "Unsupported delay unit: #{delay_unit.inspect}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def email_key_registered
|
|
47
|
+
return if email_key.blank? || RailsDrips.configuration.mailers.registered?(email_key)
|
|
48
|
+
|
|
49
|
+
errors.add(:email_key, "is not registered")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class ArchiveCampaign
|
|
5
|
+
def self.call(campaign:, reason: nil, actor: nil)
|
|
6
|
+
campaign = campaign.is_a?(Campaign) ? campaign : Campaign.find_by(key: campaign.to_s)
|
|
7
|
+
return Result.new(status: :campaign_not_found) unless campaign
|
|
8
|
+
return Result.new(status: :already_archived, campaign: campaign) if campaign.archived?
|
|
9
|
+
|
|
10
|
+
Campaign.transaction do
|
|
11
|
+
campaign.update!(status: :archived)
|
|
12
|
+
campaign.enrollments.open.find_each do |enrollment|
|
|
13
|
+
enrollment.deliveries.scheduled.find_each { |delivery| delivery.cancel!(reason: :campaign_archived) }
|
|
14
|
+
RecordEvent.call(enrollment: enrollment, event_type: "campaign_archived", actor: actor,
|
|
15
|
+
data: { "reason" => reason }.compact)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
Instrumentation.instrument("campaign.archived", campaign_id: campaign.id)
|
|
19
|
+
Result.new(status: :archived, campaign: campaign)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class CancelDelivery
|
|
5
|
+
def self.call(delivery:, reason:, actor: nil)
|
|
6
|
+
delivery.with_lock do
|
|
7
|
+
return Result.new(status: :not_cancellable, delivery: delivery) unless delivery.scheduled? || delivery.failed?
|
|
8
|
+
|
|
9
|
+
delivery.cancel!(reason: reason)
|
|
10
|
+
RecordEvent.call(enrollment: delivery.enrollment, event_type: "delivery_cancelled", actor: actor,
|
|
11
|
+
data: { "delivery_id" => delivery.id, "reason" => reason.to_s })
|
|
12
|
+
end
|
|
13
|
+
CompleteEnrollment.call(enrollment: delivery.enrollment)
|
|
14
|
+
Result.new(status: :cancelled, delivery: delivery, enrollment: delivery.enrollment)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RailsDrips
|
|
6
|
+
class CompareDefinition
|
|
7
|
+
EXECUTABLE_FIELDS = %w[position name kind delay_amount delay_unit email_key email_category data].freeze
|
|
8
|
+
|
|
9
|
+
def self.fingerprint_definition(definition)
|
|
10
|
+
steps = definition.fetch("campaign").fetch("steps").map do |step|
|
|
11
|
+
EXECUTABLE_FIELDS.to_h { |field| [field, step[field]] }
|
|
12
|
+
end
|
|
13
|
+
JSON.generate(steps.sort_by { |step| step["position"] })
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.fingerprint_version(version)
|
|
17
|
+
steps = version.steps.order(:position).map do |step|
|
|
18
|
+
{
|
|
19
|
+
"position" => step.position, "name" => step.name, "kind" => step.kind,
|
|
20
|
+
"delay_amount" => step.delay_amount, "delay_unit" => step.delay_unit,
|
|
21
|
+
"email_key" => step.email_key, "email_category" => step.email_category,
|
|
22
|
+
"data" => step.data_value.presence
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
JSON.generate(steps)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class CompleteEnrollment
|
|
5
|
+
BLOCKING_STATUSES = %w[scheduled sending failed].freeze
|
|
6
|
+
|
|
7
|
+
def self.call(enrollment:)
|
|
8
|
+
enrollment.with_lock do
|
|
9
|
+
return Result.new(status: :not_active, enrollment: enrollment) unless enrollment.active?
|
|
10
|
+
return Result.new(status: :pending, enrollment: enrollment) if enrollment.deliveries.where(status: BLOCKING_STATUSES).exists?
|
|
11
|
+
|
|
12
|
+
enrollment.complete!
|
|
13
|
+
RecordEvent.call(enrollment: enrollment, event_type: "completed")
|
|
14
|
+
Result.new(status: :completed, enrollment: enrollment)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class EnqueueDueDeliveries
|
|
5
|
+
def self.call(campaign: nil, dry_run: false)
|
|
6
|
+
campaign = Campaign.find_by(key: campaign.to_s) unless campaign.nil? || campaign.is_a?(Campaign)
|
|
7
|
+
scope = Delivery.due.joins(:enrollment).merge(Enrollment.status_active)
|
|
8
|
+
scope = scope.where(rails_drips_enrollments: { campaign_id: campaign.id }) if campaign
|
|
9
|
+
ids = scope.pluck(:id)
|
|
10
|
+
unless dry_run
|
|
11
|
+
ids.each { |id| RailsDrips.configuration.delivery_job.perform_later(id) }
|
|
12
|
+
end
|
|
13
|
+
Result.new(status: dry_run ? :dry_run : :enqueued, campaign: campaign, count: ids.length)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class Enroll
|
|
5
|
+
def self.call(...) = new(...).call
|
|
6
|
+
|
|
7
|
+
def initialize(campaign:, recipient:, actor: nil, source: nil, data: {}, force: false)
|
|
8
|
+
@campaign = campaign.is_a?(Campaign) ? campaign : Campaign.find_by(key: campaign.to_s)
|
|
9
|
+
@recipient = recipient
|
|
10
|
+
@actor = actor
|
|
11
|
+
@source = source
|
|
12
|
+
@data = data || {}
|
|
13
|
+
@force = force
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
return result(:campaign_inactive) unless campaign&.active_for_enrollment?
|
|
18
|
+
|
|
19
|
+
existing = campaign.enrollments.open.find_by(recipient: recipient)
|
|
20
|
+
return result(:already_open, enrollment: existing) if existing
|
|
21
|
+
return result(:reenrollment_disallowed) if prior_enrollment? && !campaign.allow_reenrollment?
|
|
22
|
+
|
|
23
|
+
decision = eligibility_decision
|
|
24
|
+
return result(:ineligible, reason: decision.reason) unless force || decision.allowed
|
|
25
|
+
|
|
26
|
+
enrollment = persist_enrollment
|
|
27
|
+
result(:enrolled, enrollment: enrollment)
|
|
28
|
+
rescue ActiveRecord::RecordNotUnique
|
|
29
|
+
result(:already_open, enrollment: campaign.enrollments.open.find_by(recipient: recipient))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
attr_reader :campaign, :recipient, :actor, :source, :data, :force
|
|
35
|
+
|
|
36
|
+
def prior_enrollment?
|
|
37
|
+
campaign.enrollments.where(recipient: recipient).exists?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def eligibility_decision
|
|
41
|
+
value = RailsDrips.configuration.eligibility_evaluator.call(
|
|
42
|
+
recipient: recipient, campaign: campaign, phase: :enrollment
|
|
43
|
+
)
|
|
44
|
+
PolicyDecision.call(value)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def persist_enrollment
|
|
48
|
+
email = RailsDrips.configuration.recipient_email(recipient)
|
|
49
|
+
now = RailsDrips.configuration.current_time
|
|
50
|
+
Enrollment.transaction do
|
|
51
|
+
enrollment = campaign.enrollments.create!(
|
|
52
|
+
campaign_version: campaign.current_version,
|
|
53
|
+
recipient: recipient,
|
|
54
|
+
added_by: actor,
|
|
55
|
+
entry_source: source,
|
|
56
|
+
enrolled_at: now,
|
|
57
|
+
data: data.stringify_keys.merge("forced_enrollment" => force)
|
|
58
|
+
)
|
|
59
|
+
RecordEvent.call(enrollment: enrollment, event_type: "enrolled", actor: actor,
|
|
60
|
+
data: { "source" => source, "forced" => force }.compact)
|
|
61
|
+
ScheduleDeliveries.call(enrollment: enrollment, recipient_email: email)
|
|
62
|
+
enrollment
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def result(status, enrollment: nil, reason: nil)
|
|
67
|
+
Instrumentation.instrument("enrollment.#{status}", campaign_key: campaign&.key, campaign_id: campaign&.id,
|
|
68
|
+
enrollment_id: enrollment&.id, recipient_type: recipient.class.name)
|
|
69
|
+
Result.new(status: status, campaign: campaign, enrollment: enrollment, reason: reason)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class ImportDefinition
|
|
5
|
+
METADATA_FIELDS = %w[name description allow_reenrollment default_email_category data].freeze
|
|
6
|
+
|
|
7
|
+
def self.call(definition:, dry_run: false, actor: nil)
|
|
8
|
+
new(definition, dry_run: dry_run, actor: actor).call
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(definition, dry_run:, actor:)
|
|
12
|
+
@definition = definition
|
|
13
|
+
@attributes = definition.fetch("campaign")
|
|
14
|
+
@dry_run = dry_run
|
|
15
|
+
@actor = actor
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
campaign = Campaign.find_by(key: attributes.fetch("key"))
|
|
20
|
+
return Result.new(status: :would_create) if dry_run && !campaign
|
|
21
|
+
return create_campaign unless campaign
|
|
22
|
+
|
|
23
|
+
latest = campaign.campaign_versions.order(version_number: :desc).first
|
|
24
|
+
executable_changed = latest.nil? || CompareDefinition.fingerprint_version(latest) != CompareDefinition.fingerprint_definition(definition)
|
|
25
|
+
metadata_changed = metadata_attributes.any? { |key, value| campaign.public_send(key) != value }
|
|
26
|
+
return Result.new(status: executable_changed ? :would_create_version : :would_update_metadata, campaign: campaign) if dry_run && (executable_changed || metadata_changed)
|
|
27
|
+
return Result.new(status: :unchanged, campaign: campaign) unless executable_changed || metadata_changed
|
|
28
|
+
|
|
29
|
+
Campaign.transaction do
|
|
30
|
+
campaign.update!(metadata_attributes) if metadata_changed
|
|
31
|
+
create_version(campaign) if executable_changed
|
|
32
|
+
end
|
|
33
|
+
Result.new(status: executable_changed ? :version_created : :metadata_updated, campaign: campaign)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
attr_reader :definition, :attributes, :dry_run, :actor
|
|
39
|
+
|
|
40
|
+
def create_campaign
|
|
41
|
+
campaign = Campaign.transaction do
|
|
42
|
+
created = Campaign.create!({ key: attributes.fetch("key"), status: :draft }.merge(metadata_attributes))
|
|
43
|
+
create_version(created)
|
|
44
|
+
created
|
|
45
|
+
end
|
|
46
|
+
Result.new(status: :created, campaign: campaign)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def create_version(campaign)
|
|
50
|
+
version = campaign.campaign_versions.create!(
|
|
51
|
+
version_number: campaign.campaign_versions.maximum(:version_number).to_i + 1,
|
|
52
|
+
status: :draft,
|
|
53
|
+
created_by: actor
|
|
54
|
+
)
|
|
55
|
+
attributes.fetch("steps").each do |step|
|
|
56
|
+
version.steps.create!(step.slice(*CompareDefinition::EXECUTABLE_FIELDS))
|
|
57
|
+
end
|
|
58
|
+
version
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def metadata_attributes
|
|
62
|
+
@metadata_attributes ||= attributes.slice(*METADATA_FIELDS).tap do |values|
|
|
63
|
+
values["allow_reenrollment"] = false unless values.key?("allow_reenrollment")
|
|
64
|
+
values["data"] ||= {}
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class IntegrityAudit
|
|
5
|
+
Finding = Struct.new(:code, :severity, :record_type, :record_id, :details, keyword_init: true)
|
|
6
|
+
|
|
7
|
+
def self.call(stuck_before: 1.hour.ago)
|
|
8
|
+
findings = []
|
|
9
|
+
Enrollment.status_active.includes({ campaign_version: :steps }, :deliveries).find_each do |enrollment|
|
|
10
|
+
missing = enrollment.campaign_version.steps.map(&:id) - enrollment.deliveries.map(&:step_id)
|
|
11
|
+
missing.each do |step_id|
|
|
12
|
+
findings << Finding.new(code: :missing_delivery, severity: :error,
|
|
13
|
+
record_type: enrollment.class.name, record_id: enrollment.id, details: { step_id: step_id })
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
Delivery.joins(:step, :enrollment)
|
|
17
|
+
.where("rails_drips_steps.campaign_version_id != rails_drips_enrollments.campaign_version_id")
|
|
18
|
+
.find_each { |delivery| findings << finding(:cross_version_step, :error, delivery) }
|
|
19
|
+
Delivery.status_sending.where("updated_at < ?", stuck_before).find_each do |delivery|
|
|
20
|
+
findings << finding(:stuck_sending, :error, delivery)
|
|
21
|
+
end
|
|
22
|
+
Delivery.status_sent.where(subject_snapshot: nil).find_each do |delivery|
|
|
23
|
+
findings << finding(:sent_without_snapshot, :warning, delivery)
|
|
24
|
+
end
|
|
25
|
+
Delivery.status_scheduled.joins(:enrollment).merge(Enrollment.status_removed).find_each do |delivery|
|
|
26
|
+
findings << finding(:removed_with_scheduled, :error, delivery)
|
|
27
|
+
end
|
|
28
|
+
Delivery.status_failed.joins(:enrollment).merge(Enrollment.status_completed).find_each do |delivery|
|
|
29
|
+
findings << finding(:completed_with_failed, :error, delivery)
|
|
30
|
+
end
|
|
31
|
+
queue = QueueInventory.call
|
|
32
|
+
findings << Finding.new(code: :queue_inventory_available, severity: :warning,
|
|
33
|
+
record_type: "Queue", record_id: nil, details: { size: queue.size }) if queue
|
|
34
|
+
findings
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.finding(code, severity, record)
|
|
38
|
+
Finding.new(code: code, severity: severity, record_type: record.class.name,
|
|
39
|
+
record_id: record.id, details: {})
|
|
40
|
+
end
|
|
41
|
+
private_class_method :finding
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module RailsDrips
|
|
6
|
+
class MailSnapshot
|
|
7
|
+
def self.call(message)
|
|
8
|
+
html = body_for(message, :html)
|
|
9
|
+
text = body_for(message, :text)
|
|
10
|
+
encoded = message.encoded
|
|
11
|
+
{
|
|
12
|
+
subject: message.subject.to_s,
|
|
13
|
+
html_body: html,
|
|
14
|
+
text_body: text,
|
|
15
|
+
sha256: Digest::SHA256.hexdigest(encoded),
|
|
16
|
+
byte_size: encoded.bytesize
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.body_for(message, kind)
|
|
21
|
+
part = kind == :html ? message.html_part : message.text_part
|
|
22
|
+
return part.body.decoded if part
|
|
23
|
+
return nil if message.multipart?
|
|
24
|
+
|
|
25
|
+
mime = message.mime_type.to_s
|
|
26
|
+
return message.body.decoded if kind == :html && mime.include?("html")
|
|
27
|
+
return message.body.decoded if kind == :text && !mime.include?("html")
|
|
28
|
+
end
|
|
29
|
+
private_class_method :body_for
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class PauseCampaign
|
|
5
|
+
def self.call(campaign:, actor: nil)
|
|
6
|
+
campaign = campaign.is_a?(Campaign) ? campaign : Campaign.find_by(key: campaign.to_s)
|
|
7
|
+
return Result.new(status: :campaign_not_found) unless campaign
|
|
8
|
+
return Result.new(status: :already_paused, campaign: campaign) if campaign.paused?
|
|
9
|
+
return Result.new(status: :invalid_transition, campaign: campaign) unless campaign.active?
|
|
10
|
+
|
|
11
|
+
campaign.update!(status: :paused)
|
|
12
|
+
Instrumentation.instrument("campaign.paused", campaign_id: campaign.id, actor_type: actor&.class&.name)
|
|
13
|
+
Result.new(status: :paused, campaign: campaign)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class PolicyDecision
|
|
5
|
+
Decision = Struct.new(:allowed, :reason, keyword_init: true)
|
|
6
|
+
|
|
7
|
+
def self.call(value)
|
|
8
|
+
return Decision.new(allowed: value) if value == true || value == false
|
|
9
|
+
|
|
10
|
+
value = value.to_h.transform_keys(&:to_sym)
|
|
11
|
+
Decision.new(
|
|
12
|
+
allowed: value.fetch(:allowed, value.fetch(:eligible, false)),
|
|
13
|
+
reason: value[:reason]
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.allowed?(value)
|
|
18
|
+
call(value).allowed
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class RecordEvent
|
|
5
|
+
def self.call(enrollment:, event_type:, actor: nil, data: {})
|
|
6
|
+
enrollment.enrollment_events.create!(
|
|
7
|
+
event_type: event_type,
|
|
8
|
+
actor: actor,
|
|
9
|
+
occurred_at: RailsDrips.configuration.current_time,
|
|
10
|
+
data: data || {}
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class Remove
|
|
5
|
+
def self.call(campaign:, recipient:, reason:, actor: nil, data: {})
|
|
6
|
+
campaign = campaign.is_a?(Campaign) ? campaign : Campaign.find_by(key: campaign.to_s)
|
|
7
|
+
return Result.new(status: :campaign_not_found) unless campaign
|
|
8
|
+
|
|
9
|
+
enrollment = campaign.enrollments.open.find_by(recipient: recipient)
|
|
10
|
+
return Result.new(status: :not_open, campaign: campaign) unless enrollment
|
|
11
|
+
|
|
12
|
+
Enrollment.transaction do
|
|
13
|
+
enrollment.lock!
|
|
14
|
+
enrollment.remove!(reason: reason, actor: actor)
|
|
15
|
+
RecordEvent.call(enrollment: enrollment, event_type: "removed", actor: actor,
|
|
16
|
+
data: (data || {}).stringify_keys.merge("reason" => reason.to_s))
|
|
17
|
+
enrollment.deliveries.scheduled.find_each do |delivery|
|
|
18
|
+
delivery.cancel!(reason: :recipient_removed)
|
|
19
|
+
RecordEvent.call(enrollment: enrollment, event_type: "delivery_cancelled", actor: actor,
|
|
20
|
+
data: { "delivery_id" => delivery.id, "reason" => "recipient_removed" })
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
Instrumentation.instrument("enrollment.removed", campaign_key: campaign.key, enrollment_id: enrollment.id)
|
|
24
|
+
Result.new(status: :removed, campaign: campaign, enrollment: enrollment)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class ResumeCampaign
|
|
5
|
+
def self.call(campaign:, actor: nil)
|
|
6
|
+
campaign = campaign.is_a?(Campaign) ? campaign : Campaign.find_by(key: campaign.to_s)
|
|
7
|
+
return Result.new(status: :campaign_not_found) unless campaign
|
|
8
|
+
return Result.new(status: :already_active, campaign: campaign) if campaign.active?
|
|
9
|
+
return Result.new(status: :invalid_transition, campaign: campaign) unless campaign.paused?
|
|
10
|
+
|
|
11
|
+
campaign.update!(status: :active)
|
|
12
|
+
EnqueueDueDeliveries.call(campaign: campaign)
|
|
13
|
+
Instrumentation.instrument("campaign.resumed", campaign_id: campaign.id, actor_type: actor&.class&.name)
|
|
14
|
+
Result.new(status: :resumed, campaign: campaign)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class RetryDelivery
|
|
5
|
+
def self.call(delivery:, actor: nil)
|
|
6
|
+
delivery.with_lock do
|
|
7
|
+
enrollment = delivery.enrollment
|
|
8
|
+
unless delivery.failed? && enrollment.active? && enrollment.campaign.active?
|
|
9
|
+
return Result.new(status: :not_retryable, delivery: delivery, enrollment: enrollment)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
delivery.update!(status: :scheduled, scheduled_for: RailsDrips.configuration.current_time,
|
|
13
|
+
failed_at: nil, last_error: nil)
|
|
14
|
+
RecordEvent.call(enrollment: enrollment, event_type: "delivery_retried", actor: actor,
|
|
15
|
+
data: { "delivery_id" => delivery.id })
|
|
16
|
+
end
|
|
17
|
+
RailsDrips.configuration.delivery_job.perform_later(delivery.id)
|
|
18
|
+
Result.new(status: :retried, delivery: delivery, enrollment: delivery.enrollment)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class ScheduleDeliveries
|
|
5
|
+
def self.call(enrollment:, recipient_email:)
|
|
6
|
+
scheduled_for = enrollment.enrolled_at
|
|
7
|
+
|
|
8
|
+
enrollment.campaign_version.steps.order(:position).map do |step|
|
|
9
|
+
scheduled_for += step.delay_duration
|
|
10
|
+
enrollment.deliveries.create!(
|
|
11
|
+
step: step,
|
|
12
|
+
recipient: enrollment.recipient,
|
|
13
|
+
recipient_email: recipient_email,
|
|
14
|
+
scheduled_for: scheduled_for,
|
|
15
|
+
campaign_key: enrollment.campaign.key,
|
|
16
|
+
email_key: step.email_key,
|
|
17
|
+
email_category: step.resolved_email_category,
|
|
18
|
+
data: { "step_position" => step.position, "version_number" => enrollment.campaign_version.version_number }
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class UnarchiveCampaign
|
|
5
|
+
def self.call(campaign:)
|
|
6
|
+
campaign = campaign.is_a?(Campaign) ? campaign : Campaign.find_by(key: campaign.to_s)
|
|
7
|
+
return Result.new(status: :campaign_not_found) unless campaign
|
|
8
|
+
return Result.new(status: :invalid_transition, campaign: campaign) unless campaign.archived?
|
|
9
|
+
|
|
10
|
+
campaign.update!(status: :draft, current_version: nil)
|
|
11
|
+
Result.new(status: :unarchived, campaign: campaign)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<p><%= link_to "Campaigns", campaigns_path %></p>
|
|
2
|
+
<h1><%= @campaign.name %></h1>
|
|
3
|
+
<p><code><%= @campaign.key %></code> — <%= @campaign.status %></p>
|
|
4
|
+
<h2>Versions</h2>
|
|
5
|
+
<ul><% @campaign.campaign_versions.each do |version| %><li>v<%= version.version_number %> — <%= version.status %> — <%= version.steps.size %> steps</li><% end %></ul>
|
|
6
|
+
<h2>Enrollments</h2>
|
|
7
|
+
<ul><% @enrollments.each do |enrollment| %><li><%= link_to "Enrollment ##{enrollment.id}", enrollment_path(enrollment) %> — <%= enrollment.status %></li><% end %></ul>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<h1>Delivery #<%= @delivery.id %></h1>
|
|
2
|
+
<dl><dt>Status</dt><dd><%= @delivery.status %></dd><dt>Email key</dt><dd><%= @delivery.email_key %></dd><dt>Scheduled</dt><dd><%= @delivery.scheduled_for %></dd></dl>
|
|
3
|
+
<% if @show_snapshot %>
|
|
4
|
+
<h2><%= @delivery.subject_snapshot %></h2>
|
|
5
|
+
<h3>Text</h3><pre><%= @delivery.text_body_snapshot %></pre>
|
|
6
|
+
<h3>HTML source</h3><pre><%= @delivery.html_body_snapshot %></pre>
|
|
7
|
+
<% else %>
|
|
8
|
+
<%= link_to "Show protected snapshot", delivery_path(@delivery, snapshot: 1) %>
|
|
9
|
+
<% end %>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<h1>Enrollment #<%= @enrollment.id %></h1>
|
|
2
|
+
<p><%= recipient_label(@enrollment.recipient) %> — <%= @enrollment.status %></p>
|
|
3
|
+
<h2>Events</h2>
|
|
4
|
+
<ol><% @enrollment.enrollment_events.chronological.each do |event| %><li><%= event.occurred_at %> — <%= event.event_type %></li><% end %></ol>
|
|
5
|
+
<h2>Deliveries</h2>
|
|
6
|
+
<ul><% @enrollment.deliveries.order(:scheduled_for).each do |delivery| %><li><%= link_to delivery.email_key, delivery_path(delivery) %> — <%= delivery.status %></li><% end %></ul>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RailsDrips::Engine.routes.draw do
|
|
4
|
+
resources :campaigns, only: %i[index show] do
|
|
5
|
+
resource :pause, only: :create, controller: :campaign_pauses
|
|
6
|
+
resource :resumption, only: :create, controller: :campaign_resumptions
|
|
7
|
+
resource :archival, only: :create, controller: :campaign_archivals
|
|
8
|
+
end
|
|
9
|
+
resources :enrollments, only: :show
|
|
10
|
+
resources :deliveries, only: :show do
|
|
11
|
+
resource :retry, only: :create, controller: :delivery_retries
|
|
12
|
+
resource :cancellation, only: :create, controller: :delivery_cancellations
|
|
13
|
+
end
|
|
14
|
+
end
|