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
data/docs/operations.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Operations
|
|
2
|
+
|
|
3
|
+
RailsDrips retains enough state to explain who was enrolled, which frozen campaign version they received, what was scheduled, and what happened to each delivery.
|
|
4
|
+
|
|
5
|
+
## Secure the admin
|
|
6
|
+
|
|
7
|
+
Mounting the engine does not expose records by default. Access fails closed with `404` until all required adapters are configured:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# config/routes.rb
|
|
11
|
+
mount RailsDrips::Engine => "/drips"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
RailsDrips.configure do |config|
|
|
16
|
+
config.admin_enabled = true
|
|
17
|
+
|
|
18
|
+
config.admin_authenticator = ->(**) { Current.user.present? }
|
|
19
|
+
|
|
20
|
+
config.admin_authorizer = ->(action:, **context) { Current.user.admin? }
|
|
21
|
+
|
|
22
|
+
config.current_actor_resolver = ->(_controller) { Current.user }
|
|
23
|
+
|
|
24
|
+
config.snapshot_authorizer = ->(delivery:, **context) do
|
|
25
|
+
Current.user.can_view_email_content?
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Authentication establishes that someone is signed in. Authorization decides whether that identity may use the operational interface. Snapshot authorization is separate because stored email bodies may contain sensitive recipient data.
|
|
31
|
+
|
|
32
|
+
The engine controller currently inherits `ActionController::Base`, not the host's `ApplicationController`. Authentication methods mixed into all controllers may be available, but host-only controller methods are not guaranteed. A request-scoped object such as `Current.user` is the most portable adapter until configurable controller inheritance is added.
|
|
33
|
+
|
|
34
|
+
With a `/drips` mount, the prototype begins at `/drips/campaigns`. It supports inspection plus campaign pause/resume/archive and delivery retry/cancel. It is not yet the intended authoring experience; see the [admin UI TODO](todo/admin-ui-and-dummy-app.md).
|
|
35
|
+
|
|
36
|
+
## Campaign operations
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
RailsDrips.pause_campaign(campaign: campaign, actor: operator)
|
|
40
|
+
RailsDrips.resume_campaign(campaign: campaign, actor: operator)
|
|
41
|
+
RailsDrips.archive_campaign(campaign: campaign, reason: "superseded", actor: operator)
|
|
42
|
+
RailsDrips.unarchive_campaign(campaign: campaign)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Pause leaves scheduled rows intact and prevents sending. Resume returns the campaign to active and enqueues due rows. Archive cancels future scheduled work.
|
|
46
|
+
|
|
47
|
+
## Retry and cancellation
|
|
48
|
+
|
|
49
|
+
Failed deliveries do not silently become scheduled again. Retry is explicit and permitted only while enrollment and campaign are active:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
RailsDrips::RetryDelivery.call(delivery: delivery, actor: operator)
|
|
53
|
+
RailsDrips::CancelDelivery.call(delivery: delivery, reason: "operator_cancelled", actor: operator)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Failures store a bounded error, attempt count, timestamp, and event before the job exception is re-raised to the host adapter.
|
|
57
|
+
|
|
58
|
+
## Due-delivery repair
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
RailsDrips.enqueue_due_deliveries(dry_run: true).count
|
|
62
|
+
RailsDrips.enqueue_due_deliveries(campaign: campaign, dry_run: true).count
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Then enqueue due work with `bin/rails rails_drips:enqueue_due`. This is a repair tool, not a replacement for running the normal worker.
|
|
66
|
+
|
|
67
|
+
## Integrity audit
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
RailsDrips::IntegrityAudit.call.each do |finding|
|
|
71
|
+
Rails.logger.warn(
|
|
72
|
+
code: finding.code,
|
|
73
|
+
severity: finding.severity,
|
|
74
|
+
record_type: finding.record_type,
|
|
75
|
+
record_id: finding.record_id,
|
|
76
|
+
details: finding.details
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The audit checks missing step deliveries, cross-version steps, stuck sending rows, scheduled work for removed enrollments, failed work on completed enrollments, and sent rows without snapshots.
|
|
82
|
+
|
|
83
|
+
Active Job has no portable queue-inspection API. A host can optionally add queue evidence:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
config.queue_inventory = -> { MyQueueAudit.rails_drips_job_ids }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## The ambiguous transport window
|
|
90
|
+
|
|
91
|
+
RailsDrips marks a delivery `sending`, releases its lock, and performs transport I/O. If the provider accepts the email and the process dies before `sent` is persisted, database state alone cannot prove delivery.
|
|
92
|
+
|
|
93
|
+
Before retrying a stuck `sending` row:
|
|
94
|
+
|
|
95
|
+
1. Search provider logs using the RailsDrips delivery ID or correlation header.
|
|
96
|
+
2. Determine whether the provider accepted the message.
|
|
97
|
+
3. Reconcile accepted work according to the host incident procedure.
|
|
98
|
+
4. Retry only when evidence indicates transport did not accept it.
|
|
99
|
+
|
|
100
|
+
## Monitoring
|
|
101
|
+
|
|
102
|
+
Watch scheduled deliveries past their processing window, stuck `sending` rows, failure rate, repeated attempts, due backlog by campaign, cancellation reasons, enrollment completion, and Active Job queue latency.
|
|
103
|
+
|
|
104
|
+
Pause an affected campaign if content, policy, preference, or transport behavior is wrong. Pausing is the fastest reversible way to stop new sends without deleting evidence.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/migration"
|
|
5
|
+
require "rails/generators/active_record"
|
|
6
|
+
|
|
7
|
+
module RailsDrips
|
|
8
|
+
module Generators
|
|
9
|
+
class InstallGenerator < Rails::Generators::Base
|
|
10
|
+
include Rails::Generators::Migration
|
|
11
|
+
|
|
12
|
+
source_root File.expand_path("templates", __dir__)
|
|
13
|
+
|
|
14
|
+
def copy_initializer
|
|
15
|
+
template "initializer.rb", "config/initializers/rails_drips.rb"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def copy_sample_definition
|
|
19
|
+
template "sample.yml", "config/rails_drips/onboarding.yml"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def copy_migration
|
|
23
|
+
migration_template File.expand_path("../../../../db/migrate/20260829123000_create_rails_drips_tables.rb", __dir__),
|
|
24
|
+
"db/migrate/create_rails_drips_tables.rb"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.next_migration_number(dirname)
|
|
28
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
schema_version: 1
|
|
2
|
+
campaign:
|
|
3
|
+
key: onboarding
|
|
4
|
+
name: Onboarding
|
|
5
|
+
description: A sample onboarding campaign
|
|
6
|
+
allow_reenrollment: false
|
|
7
|
+
steps:
|
|
8
|
+
- position: 1
|
|
9
|
+
name: Welcome
|
|
10
|
+
kind: email
|
|
11
|
+
delay_amount: 0
|
|
12
|
+
delay_unit: minutes
|
|
13
|
+
email_key: welcome
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :delivery_queue_name,
|
|
6
|
+
:delivery_job_class,
|
|
7
|
+
:eligibility_evaluator,
|
|
8
|
+
:logger,
|
|
9
|
+
:admin_enabled,
|
|
10
|
+
:admin_authenticator,
|
|
11
|
+
:admin_authorizer,
|
|
12
|
+
:preference_evaluator,
|
|
13
|
+
:queue_inventory,
|
|
14
|
+
:recipient_email_resolver,
|
|
15
|
+
:recipient_label_resolver,
|
|
16
|
+
:current_actor_resolver,
|
|
17
|
+
:snapshot_authorizer,
|
|
18
|
+
:time_source,
|
|
19
|
+
:transport_metadata_hook
|
|
20
|
+
attr_reader :mailers
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@delivery_queue_name = :default
|
|
24
|
+
@delivery_job_class = -> { RailsDrips::DeliveryJob }
|
|
25
|
+
@eligibility_evaluator = ->(**) { true }
|
|
26
|
+
@logger = defined?(Rails) ? Rails.logger : nil
|
|
27
|
+
@admin_enabled = false
|
|
28
|
+
@admin_authenticator = nil
|
|
29
|
+
@admin_authorizer = nil
|
|
30
|
+
@preference_evaluator = ->(**) { true }
|
|
31
|
+
@queue_inventory = nil
|
|
32
|
+
@recipient_email_resolver = method(:default_recipient_email)
|
|
33
|
+
@recipient_label_resolver = ->(recipient) { recipient.respond_to?(:name) ? recipient.name : "#{recipient.class.name} ##{recipient.id}" }
|
|
34
|
+
@current_actor_resolver = ->(_controller) { nil }
|
|
35
|
+
@snapshot_authorizer = ->(**) { false }
|
|
36
|
+
@time_source = -> { Time.current }
|
|
37
|
+
@transport_metadata_hook = ->(message:, **) { message }
|
|
38
|
+
@mailers = MailRegistry.new
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def delivery_job
|
|
42
|
+
delivery_job_class.respond_to?(:call) ? delivery_job_class.call : delivery_job_class
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def current_time
|
|
46
|
+
time_source.call
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def recipient_email(recipient)
|
|
50
|
+
value = recipient_email_resolver.call(recipient).to_s.strip
|
|
51
|
+
return value unless value.empty?
|
|
52
|
+
|
|
53
|
+
raise RecipientEmailMissing, "No email address could be resolved for #{recipient.class.name}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def default_recipient_email(recipient)
|
|
59
|
+
return recipient.email_address if recipient.respond_to?(:email_address)
|
|
60
|
+
return recipient.email if recipient.respond_to?(:email)
|
|
61
|
+
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module RailsDrips
|
|
6
|
+
class DefinitionLoader
|
|
7
|
+
def self.load_file(path)
|
|
8
|
+
load_string(File.read(path), source: path.to_s)
|
|
9
|
+
rescue Errno::ENOENT => error
|
|
10
|
+
raise DefinitionInvalid, [error.message]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.load_string(yaml, source: nil)
|
|
14
|
+
raw = YAML.safe_load(yaml, permitted_classes: [], permitted_symbols: [], aliases: false)
|
|
15
|
+
definition = deep_stringify(raw)
|
|
16
|
+
normalize!(definition)
|
|
17
|
+
errors = DefinitionSchema.validate(definition)
|
|
18
|
+
raise DefinitionInvalid, errors.map { |error| source ? "#{source}: #{error}" : error } if errors.any?
|
|
19
|
+
|
|
20
|
+
definition
|
|
21
|
+
rescue Psych::Exception => error
|
|
22
|
+
raise DefinitionInvalid, [source ? "#{source}: #{error.message}" : error.message]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.deep_stringify(value)
|
|
26
|
+
case value
|
|
27
|
+
when Hash then value.to_h { |key, nested| [key.to_s, deep_stringify(nested)] }
|
|
28
|
+
when Array then value.map { |nested| deep_stringify(nested) }
|
|
29
|
+
else value
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.normalize!(definition)
|
|
34
|
+
return definition unless definition.is_a?(Hash) && definition["campaign"].is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
campaign = definition["campaign"]
|
|
37
|
+
campaign["steps"]&.each { |step| step["kind"] ||= "email" if step.is_a?(Hash) }
|
|
38
|
+
definition
|
|
39
|
+
end
|
|
40
|
+
private_class_method :deep_stringify, :normalize!
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class DefinitionSchema
|
|
5
|
+
SCHEMA_VERSION = 1
|
|
6
|
+
|
|
7
|
+
def self.validate(definition)
|
|
8
|
+
errors = []
|
|
9
|
+
return ["definition must be a mapping"] unless definition.is_a?(Hash)
|
|
10
|
+
|
|
11
|
+
errors << "schema_version must be #{SCHEMA_VERSION}" unless definition["schema_version"] == SCHEMA_VERSION
|
|
12
|
+
campaign = definition["campaign"]
|
|
13
|
+
return errors << "campaign must be a mapping" unless campaign.is_a?(Hash)
|
|
14
|
+
|
|
15
|
+
errors << "campaign.key cannot be blank" if campaign["key"].to_s.strip.empty?
|
|
16
|
+
errors << "campaign.name cannot be blank" if campaign["name"].to_s.strip.empty?
|
|
17
|
+
steps = campaign["steps"]
|
|
18
|
+
return errors << "campaign.steps must be a non-empty list" unless steps.is_a?(Array) && steps.any?
|
|
19
|
+
|
|
20
|
+
positions = steps.filter_map { |step| step["position"] if step.is_a?(Hash) }
|
|
21
|
+
errors << "campaign.steps positions must be unique" if positions.uniq.length != positions.length
|
|
22
|
+
steps.each_with_index { |step, index| validate_step(step, index, errors) }
|
|
23
|
+
errors
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.validate_step(step, index, errors)
|
|
27
|
+
path = "campaign.steps[#{index}]"
|
|
28
|
+
return errors << "#{path} must be a mapping" unless step.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
errors << "#{path}.position must be a positive integer" unless step["position"].is_a?(Integer) && step["position"].positive?
|
|
31
|
+
errors << "#{path}.name cannot be blank" if step["name"].to_s.strip.empty?
|
|
32
|
+
errors << "#{path}.delay_amount must be a non-negative integer" unless step["delay_amount"].is_a?(Integer) && step["delay_amount"] >= 0
|
|
33
|
+
errors << "#{path}.delay_unit must be minutes, hours, or days" unless Step::DELAY_UNITS.include?(step["delay_unit"].to_s)
|
|
34
|
+
email_key = step["email_key"].to_s
|
|
35
|
+
errors << "#{path}.email_key #{email_key.inspect} is not registered" unless RailsDrips.configuration.mailers.registered?(email_key)
|
|
36
|
+
errors << "#{path}.kind must be email" unless step.fetch("kind", "email").to_s == "email"
|
|
37
|
+
end
|
|
38
|
+
private_class_method :validate_step
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
class ConfigurationError < Error; end
|
|
6
|
+
class DuplicateMailerKey < ConfigurationError; end
|
|
7
|
+
class MailerNotRegistered < ConfigurationError; end
|
|
8
|
+
class RecipientEmailMissing < Error; end
|
|
9
|
+
class DefinitionInvalid < Error
|
|
10
|
+
attr_reader :errors
|
|
11
|
+
|
|
12
|
+
def initialize(errors)
|
|
13
|
+
@errors = Array(errors)
|
|
14
|
+
super(@errors.join("; "))
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class MailRegistry
|
|
5
|
+
Definition = Struct.new(:key, :label, :callable, keyword_init: true) do
|
|
6
|
+
def call(delivery)
|
|
7
|
+
callable.call(delivery)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
include Enumerable
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@definitions = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def register(key, label:, callable: nil, &block)
|
|
18
|
+
normalized_key = normalize_key(key)
|
|
19
|
+
delivery_callable = callable || block
|
|
20
|
+
|
|
21
|
+
raise ConfigurationError, "A mailer callable is required for #{normalized_key.inspect}" unless delivery_callable.respond_to?(:call)
|
|
22
|
+
raise ConfigurationError, "A mailer label is required for #{normalized_key.inspect}" if label.to_s.strip.empty?
|
|
23
|
+
raise DuplicateMailerKey, "Mailer key #{normalized_key.inspect} is already registered" if registered?(normalized_key)
|
|
24
|
+
|
|
25
|
+
@definitions[normalized_key] = Definition.new(
|
|
26
|
+
key: normalized_key,
|
|
27
|
+
label: label.to_s,
|
|
28
|
+
callable: delivery_callable
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def fetch(key)
|
|
33
|
+
normalized_key = normalize_key(key)
|
|
34
|
+
@definitions.fetch(normalized_key) do
|
|
35
|
+
raise MailerNotRegistered, "No mailer is registered for #{normalized_key.inspect}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def registered?(key)
|
|
40
|
+
@definitions.key?(key.to_s)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def each(&block)
|
|
44
|
+
return enum_for(:each) unless block
|
|
45
|
+
|
|
46
|
+
@definitions.values.each(&block)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def clear
|
|
50
|
+
@definitions.clear
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def normalize_key(key)
|
|
57
|
+
key.to_s.strip.tap do |normalized_key|
|
|
58
|
+
raise ConfigurationError, "Mailer key cannot be blank" if normalized_key.empty?
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
Result = Struct.new(:status, :enrollment, :campaign, :delivery, :reason, :count, keyword_init: true) do
|
|
5
|
+
def success?
|
|
6
|
+
%i[enrolled removed activated paused resumed archived unarchived enqueued retried cancelled sent completed created version_created metadata_updated unchanged].include?(status)
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
end
|
data/lib/rails_drips.rb
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails"
|
|
4
|
+
require "active_record/railtie"
|
|
5
|
+
require "active_job/railtie"
|
|
6
|
+
require "action_mailer/railtie"
|
|
7
|
+
|
|
8
|
+
require "rails_drips/version"
|
|
9
|
+
require "rails_drips/errors"
|
|
10
|
+
require "rails_drips/result"
|
|
11
|
+
require "rails_drips/mail_registry"
|
|
12
|
+
require "rails_drips/configuration"
|
|
13
|
+
require "rails_drips/instrumentation"
|
|
14
|
+
require "rails_drips/definition_schema"
|
|
15
|
+
require "rails_drips/definition_loader"
|
|
16
|
+
require "rails_drips/queue_inventory"
|
|
17
|
+
require "rails_drips/engine"
|
|
18
|
+
|
|
19
|
+
module RailsDrips
|
|
20
|
+
class << self
|
|
21
|
+
def configuration
|
|
22
|
+
@configuration ||= Configuration.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def configure
|
|
26
|
+
yield(configuration)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def reset_configuration!
|
|
30
|
+
@configuration = Configuration.new
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def enroll(**attributes)
|
|
34
|
+
Enroll.call(**attributes)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def remove(**attributes)
|
|
38
|
+
Remove.call(**attributes)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def pause_campaign(**attributes)
|
|
42
|
+
PauseCampaign.call(**attributes)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def resume_campaign(**attributes)
|
|
46
|
+
ResumeCampaign.call(**attributes)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def archive_campaign(**attributes)
|
|
50
|
+
ArchiveCampaign.call(**attributes)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def unarchive_campaign(**attributes)
|
|
54
|
+
UnarchiveCampaign.call(**attributes)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def enqueue_due_deliveries(**attributes)
|
|
58
|
+
EnqueueDueDeliveries.call(**attributes)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def table_name_prefix
|
|
62
|
+
"rails_drips_"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :rails_drips do
|
|
4
|
+
desc "Validate a campaign definition (DEFINITION=path)"
|
|
5
|
+
task validate: :environment do
|
|
6
|
+
definition = RailsDrips::DefinitionLoader.load_file(ENV.fetch("DEFINITION"))
|
|
7
|
+
puts "Valid: #{definition.dig("campaign", "key")}" # rubocop:disable Rails/Output
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
desc "Import a definition; dry-run unless APPLY=true (DEFINITION=path)"
|
|
11
|
+
task import: :environment do
|
|
12
|
+
definition = RailsDrips::DefinitionLoader.load_file(ENV.fetch("DEFINITION"))
|
|
13
|
+
result = RailsDrips::ImportDefinition.call(definition: definition, dry_run: ENV["APPLY"] != "true")
|
|
14
|
+
puts "#{result.status}: #{definition.dig("campaign", "key")}" # rubocop:disable Rails/Output
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
desc "Activate a draft campaign version (CAMPAIGN=key VERSION=number)"
|
|
18
|
+
task activate: :environment do
|
|
19
|
+
campaign = RailsDrips::Campaign.find_by!(key: ENV.fetch("CAMPAIGN"))
|
|
20
|
+
version = campaign.campaign_versions.find_by!(version_number: ENV.fetch("VERSION"))
|
|
21
|
+
puts RailsDrips::ActivateCampaign.call(campaign: campaign, version: version).status # rubocop:disable Rails/Output
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
desc "Enqueue due scheduled deliveries"
|
|
25
|
+
task enqueue_due: :environment do
|
|
26
|
+
puts RailsDrips.enqueue_due_deliveries.count # rubocop:disable Rails/Output
|
|
27
|
+
end
|
|
28
|
+
end
|