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.
Files changed (70) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +6 -0
  3. data/LICENSE.txt +4 -0
  4. data/README.md +80 -0
  5. data/app/controllers/concerns/rails_drips/admin_access.rb +28 -0
  6. data/app/controllers/rails_drips/application_controller.rb +7 -0
  7. data/app/controllers/rails_drips/campaign_archivals_controller.rb +11 -0
  8. data/app/controllers/rails_drips/campaign_pauses_controller.rb +11 -0
  9. data/app/controllers/rails_drips/campaign_resumptions_controller.rb +11 -0
  10. data/app/controllers/rails_drips/campaigns_controller.rb +14 -0
  11. data/app/controllers/rails_drips/deliveries_controller.rb +14 -0
  12. data/app/controllers/rails_drips/delivery_cancellations_controller.rb +11 -0
  13. data/app/controllers/rails_drips/delivery_retries_controller.rb +11 -0
  14. data/app/controllers/rails_drips/enrollments_controller.rb +11 -0
  15. data/app/jobs/rails_drips/delivery_job.rb +89 -0
  16. data/app/models/concerns/rails_drips/json_data.rb +29 -0
  17. data/app/models/rails_drips/application_record.rb +8 -0
  18. data/app/models/rails_drips/campaign.rb +62 -0
  19. data/app/models/rails_drips/campaign_version.rb +35 -0
  20. data/app/models/rails_drips/delivery.rb +90 -0
  21. data/app/models/rails_drips/enrollment.rb +77 -0
  22. data/app/models/rails_drips/enrollment_event.rb +28 -0
  23. data/app/models/rails_drips/step.rb +53 -0
  24. data/app/services/rails_drips/activate_campaign.rb +10 -0
  25. data/app/services/rails_drips/archive_campaign.rb +22 -0
  26. data/app/services/rails_drips/cancel_delivery.rb +17 -0
  27. data/app/services/rails_drips/compare_definition.rb +28 -0
  28. data/app/services/rails_drips/complete_enrollment.rb +18 -0
  29. data/app/services/rails_drips/enqueue_due_deliveries.rb +16 -0
  30. data/app/services/rails_drips/enroll.rb +72 -0
  31. data/app/services/rails_drips/import_definition.rb +68 -0
  32. data/app/services/rails_drips/integrity_audit.rb +43 -0
  33. data/app/services/rails_drips/mail_snapshot.rb +31 -0
  34. data/app/services/rails_drips/pause_campaign.rb +16 -0
  35. data/app/services/rails_drips/policy_decision.rb +21 -0
  36. data/app/services/rails_drips/record_event.rb +14 -0
  37. data/app/services/rails_drips/remove.rb +27 -0
  38. data/app/services/rails_drips/resume_campaign.rb +17 -0
  39. data/app/services/rails_drips/retry_delivery.rb +21 -0
  40. data/app/services/rails_drips/schedule_deliveries.rb +23 -0
  41. data/app/services/rails_drips/unarchive_campaign.rb +14 -0
  42. data/app/views/layouts/rails_drips/application.html.erb +5 -0
  43. data/app/views/rails_drips/campaigns/index.html.erb +7 -0
  44. data/app/views/rails_drips/campaigns/show.html.erb +7 -0
  45. data/app/views/rails_drips/deliveries/show.html.erb +9 -0
  46. data/app/views/rails_drips/enrollments/show.html.erb +6 -0
  47. data/config/routes.rb +14 -0
  48. data/db/migrate/20260829123000_create_rails_drips_tables.rb +142 -0
  49. data/docs/README.md +13 -0
  50. data/docs/architecture.md +95 -0
  51. data/docs/campaign-definitions.md +100 -0
  52. data/docs/getting-started.md +170 -0
  53. data/docs/host-integration.md +160 -0
  54. data/docs/operations.md +104 -0
  55. data/lib/generators/rails_drips/install/install_generator.rb +32 -0
  56. data/lib/generators/rails_drips/install/templates/initializer.rb +8 -0
  57. data/lib/generators/rails_drips/install/templates/sample.yml +13 -0
  58. data/lib/rails_drips/configuration.rb +65 -0
  59. data/lib/rails_drips/definition_loader.rb +42 -0
  60. data/lib/rails_drips/definition_schema.rb +40 -0
  61. data/lib/rails_drips/engine.rb +9 -0
  62. data/lib/rails_drips/errors.rb +17 -0
  63. data/lib/rails_drips/instrumentation.rb +11 -0
  64. data/lib/rails_drips/mail_registry.rb +62 -0
  65. data/lib/rails_drips/queue_inventory.rb +10 -0
  66. data/lib/rails_drips/result.rb +9 -0
  67. data/lib/rails_drips/version.rb +5 -0
  68. data/lib/rails_drips.rb +65 -0
  69. data/lib/tasks/rails_drips_tasks.rake +28 -0
  70. metadata +179 -0
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateRailsDripsTables < ActiveRecord::Migration[7.0]
4
+ def change
5
+ metadata_type = postgresql? ? :jsonb : :json
6
+
7
+ create_table :rails_drips_campaigns do |t|
8
+ t.string :key, null: false
9
+ t.string :name, null: false
10
+ t.text :description
11
+ t.integer :status, null: false, default: 0
12
+ t.boolean :allow_reenrollment, null: false, default: false
13
+ t.bigint :current_version_id
14
+ t.string :default_email_category
15
+ t.public_send(metadata_type, :data, null: false, default: {})
16
+ t.timestamps
17
+ end
18
+ add_index :rails_drips_campaigns, :key, unique: true
19
+ add_index :rails_drips_campaigns, :status
20
+ add_index :rails_drips_campaigns, :current_version_id
21
+
22
+ create_table :rails_drips_campaign_versions do |t|
23
+ t.references :campaign, null: false, foreign_key: { to_table: :rails_drips_campaigns }
24
+ t.integer :version_number, null: false
25
+ t.integer :status, null: false, default: 0
26
+ t.datetime :activated_at
27
+ t.references :created_by, polymorphic: true
28
+ t.timestamps
29
+ end
30
+ add_index :rails_drips_campaign_versions,
31
+ [:campaign_id, :version_number],
32
+ unique: true,
33
+ name: "idx_rails_drips_versions_campaign_number"
34
+ add_index :rails_drips_campaign_versions,
35
+ :campaign_id,
36
+ unique: true,
37
+ where: "status = 1",
38
+ name: "idx_rails_drips_versions_active_campaign"
39
+
40
+ add_foreign_key :rails_drips_campaigns,
41
+ :rails_drips_campaign_versions,
42
+ column: :current_version_id
43
+
44
+ create_table :rails_drips_steps do |t|
45
+ t.references :campaign_version, null: false, foreign_key: { to_table: :rails_drips_campaign_versions }
46
+ t.integer :position, null: false
47
+ t.string :name, null: false
48
+ t.integer :kind, null: false, default: 0
49
+ t.integer :delay_amount, null: false, default: 0
50
+ t.string :delay_unit, null: false, default: "days"
51
+ t.string :email_key, null: false
52
+ t.string :email_category
53
+ t.public_send(metadata_type, :data, null: false, default: {})
54
+ t.timestamps
55
+ end
56
+ add_index :rails_drips_steps,
57
+ [:campaign_version_id, :position],
58
+ unique: true,
59
+ name: "idx_rails_drips_steps_version_position"
60
+ add_index :rails_drips_steps, :email_key
61
+
62
+ create_table :rails_drips_enrollments do |t|
63
+ t.references :campaign, null: false, foreign_key: { to_table: :rails_drips_campaigns }
64
+ t.references :campaign_version, null: false, foreign_key: { to_table: :rails_drips_campaign_versions }
65
+ t.references :recipient, polymorphic: true, null: false
66
+ t.integer :status, null: false, default: 0
67
+ t.string :entry_source
68
+ t.string :exit_reason
69
+ t.references :added_by, polymorphic: true
70
+ t.references :removed_by, polymorphic: true
71
+ t.datetime :enrolled_at
72
+ t.datetime :paused_at
73
+ t.datetime :removed_at
74
+ t.datetime :completed_at
75
+ t.datetime :failed_at
76
+ t.public_send(metadata_type, :data, null: false, default: {})
77
+ t.timestamps
78
+ end
79
+ add_index :rails_drips_enrollments, [:campaign_id, :status]
80
+ add_index :rails_drips_enrollments, [:campaign_version_id, :status], name: "idx_rails_drips_enrollments_version_status"
81
+ add_index :rails_drips_enrollments,
82
+ [:campaign_id, :recipient_type, :recipient_id],
83
+ unique: true,
84
+ where: "status IN (0, 1)",
85
+ name: "idx_rails_drips_enrollments_open_recipient"
86
+
87
+ create_table :rails_drips_enrollment_events do |t|
88
+ t.references :enrollment, null: false, foreign_key: { to_table: :rails_drips_enrollments }
89
+ t.string :event_type, null: false
90
+ t.references :actor, polymorphic: true
91
+ t.datetime :occurred_at, null: false
92
+ t.public_send(metadata_type, :data, null: false, default: {})
93
+ t.timestamps
94
+ end
95
+ add_index :rails_drips_enrollment_events,
96
+ [:enrollment_id, :occurred_at],
97
+ name: "idx_rails_drips_events_enrollment_occurred"
98
+ add_index :rails_drips_enrollment_events, :event_type
99
+
100
+ create_table :rails_drips_deliveries do |t|
101
+ t.references :enrollment, null: false, foreign_key: { to_table: :rails_drips_enrollments }
102
+ t.references :step, null: false, foreign_key: { to_table: :rails_drips_steps }
103
+ t.references :recipient, polymorphic: true
104
+ t.string :recipient_email, null: false
105
+ t.integer :status, null: false, default: 0
106
+ t.datetime :scheduled_for, null: false
107
+ t.datetime :sent_at
108
+ t.datetime :failed_at
109
+ t.datetime :cancelled_at
110
+ t.string :campaign_key, null: false
111
+ t.string :email_key, null: false
112
+ t.string :email_category
113
+ t.string :subject_snapshot
114
+ t.text :html_body_snapshot
115
+ t.text :text_body_snapshot
116
+ t.string :provider
117
+ t.string :provider_message_id
118
+ t.integer :attempts, null: false, default: 0
119
+ t.text :last_error
120
+ t.public_send(metadata_type, :data, null: false, default: {})
121
+ t.timestamps
122
+ end
123
+ add_index :rails_drips_deliveries,
124
+ [:enrollment_id, :step_id],
125
+ unique: true,
126
+ name: "idx_rails_drips_deliveries_enrollment_step"
127
+ add_index :rails_drips_deliveries,
128
+ [:status, :scheduled_for],
129
+ name: "idx_rails_drips_deliveries_due"
130
+ add_index :rails_drips_deliveries, :recipient_email
131
+ add_index :rails_drips_deliveries, :campaign_key
132
+ add_index :rails_drips_deliveries, :email_key
133
+ add_index :rails_drips_deliveries, :provider_message_id
134
+ end
135
+
136
+ private
137
+
138
+ def postgresql?
139
+ connection.adapter_name.casecmp("PostgreSQL").zero?
140
+ end
141
+ end
142
+
data/docs/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # RailsDrips documentation
2
+
3
+ Start with [Getting started](getting-started.md) if you are adding RailsDrips to an application.
4
+
5
+ | Guide | Use it when |
6
+ | --- | --- |
7
+ | [Getting started](getting-started.md) | Installing the engine and sending the first campaign |
8
+ | [Host integration](host-integration.md) | Wiring mailers, product rules, lifecycle triggers, jobs, or admin access |
9
+ | [Architecture](architecture.md) | Understanding ownership, persistence, transactions, and delivery flow |
10
+ | [Campaign definitions](campaign-definitions.md) | Authoring or changing YAML campaigns |
11
+ | [Operations](operations.md) | Running the admin, audits, retries, and incident recovery |
12
+
13
+ The files under `research/extracting-drip-engine/` explain the extraction decisions. They are maintainer references rather than required setup reading.
@@ -0,0 +1,95 @@
1
+ # Architecture
2
+
3
+ RailsDrips is an isolated Rails engine with a headless workflow core and an optional operational UI.
4
+
5
+ ## Runtime ownership
6
+
7
+ ```mermaid
8
+ flowchart LR
9
+ Event[Host product event] --> API[RailsDrips public API]
10
+ Definition[YAML campaign definition] --> Import[Definition importer]
11
+ Import --> DB[(RailsDrips tables)]
12
+ API --> DB
13
+ DB --> Commit[After database commit]
14
+ Commit --> AJ[Host Active Job adapter]
15
+ AJ --> Job[RailsDrips delivery job]
16
+ Job --> Policy[Host eligibility and preferences]
17
+ Policy --> Registry[Host mail registry]
18
+ Registry --> Mailer[Host Action Mailer]
19
+ Mailer --> Hook[Host transport hook]
20
+ Hook --> Transport[Host mail transport]
21
+ Job --> Evidence[Snapshots and enrollment events]
22
+ Evidence --> DB
23
+ ```
24
+
25
+ The engine can schedule and audit email without knowing the host's `User` model, subscription system, mail provider, queue adapter, or product lifecycle.
26
+
27
+ ## Persistence model
28
+
29
+ | Record | Purpose |
30
+ | --- | --- |
31
+ | `RailsDrips::Campaign` | Stable campaign identity and lifecycle state |
32
+ | `RailsDrips::CampaignVersion` | Frozen executable definition used by a set of enrollments |
33
+ | `RailsDrips::Step` | Ordered email key, category, and delay within one version |
34
+ | `RailsDrips::Enrollment` | One recipient's journey through one frozen campaign version |
35
+ | `RailsDrips::Delivery` | Scheduled attempt plus recipient, content snapshot, provider receipt, and failure state |
36
+ | `RailsDrips::EnrollmentEvent` | Append-only evidence for enrollment and delivery transitions |
37
+
38
+ Recipients and actors are polymorphic associations. Host records stay in host tables.
39
+
40
+ ## Definition and version flow
41
+
42
+ 1. YAML imports into a draft campaign version.
43
+ 2. Activation makes that version current for new enrollments.
44
+ 3. Existing enrollments remain attached to their original version.
45
+ 4. Metadata-only imports update the campaign without versioning.
46
+ 5. A step, delay, key, kind, position, or category change creates a new draft.
47
+ 6. Activating the draft retires the previous active version.
48
+
49
+ The importer never edits active or retired versions, so a definition import cannot silently change messages already scheduled for recipients in flight. Direct model mutation is an internal, unsupported integration path.
50
+
51
+ ## Enrollment transaction
52
+
53
+ `RailsDrips.enroll` resolves the campaign and recipient email, checks policy and idempotency, then creates the enrollment, initial event, and all step deliveries in one transaction. Delays are cumulative from `enrolled_at`.
54
+
55
+ Each delivery enqueues through `after_create_commit`, so jobs appear only after the surrounding transaction commits—even when the host calls enrollment inside a larger transaction.
56
+
57
+ A database partial unique index permits only one open enrollment for a campaign/recipient pair. The service converts concurrency collisions into `already_open`.
58
+
59
+ ## Delivery state machine
60
+
61
+ ```mermaid
62
+ stateDiagram-v2
63
+ [*] --> scheduled
64
+ scheduled --> sending: guards pass and message is snapshotted
65
+ scheduled --> cancelled: campaign, enrollment, or preference rejects
66
+ sending --> sent: transport returns
67
+ sending --> failed: render, hook, or transport raises
68
+ failed --> scheduled: explicit retry
69
+ scheduled --> cancelled: operator or lifecycle cancellation
70
+ ```
71
+
72
+ The job locks rows for state transitions but does not hold a database lock across transport I/O. A duplicate job sees a non-scheduled row and exits.
73
+
74
+ There is an unavoidable ambiguity if the provider accepts a message and the process crashes before RailsDrips records `sent`. Delivery and provider message IDs provide correlation evidence; operators should investigate before retrying a `sending` row.
75
+
76
+ ## Campaign lifecycle
77
+
78
+ - **Draft:** definition exists but cannot enroll recipients.
79
+ - **Active:** accepts enrollments and permits delivery.
80
+ - **Paused:** does not send; scheduled rows remain available for resume.
81
+ - **Archived:** does not send or enroll and cancels future scheduled work.
82
+
83
+ Resume re-enqueues only deliveries already due. A due-repair operation is also exposed independently.
84
+
85
+ ## Public and internal surfaces
86
+
87
+ The stable host entry points are module methods on `RailsDrips`, configuration callables, definition tasks, and model data exposed to registered mailers.
88
+
89
+ Services under `RailsDrips::*` support engine controllers and testing but should not be the first integration choice when a module-level operation exists. Change engine states through lifecycle services rather than direct status updates.
90
+
91
+ ## Optional admin
92
+
93
+ The admin is a view over engine records and operations, not the source of host templates or product rules. It must remain usable without Tailwind, Turbo, Devise, GoodJob, or a particular `User` class.
94
+
95
+ The current interface is deliberately minimal. The next UI iteration adds a coherent design system, draft authoring, sample data, and a runnable dummy app; see the [admin UI TODO](todo/admin-ui-and-dummy-app.md).
@@ -0,0 +1,100 @@
1
+ # Campaign definitions
2
+
3
+ Campaign definitions are reviewable YAML files describing a campaign and its ordered email steps. They are safe-loaded: aliases and Ruby object construction are rejected.
4
+
5
+ ## Complete example
6
+
7
+ ```yaml
8
+ schema_version: 1
9
+ campaign:
10
+ key: onboarding
11
+ name: Customer onboarding
12
+ description: Helps a new customer reach their first success.
13
+ allow_reenrollment: false
14
+ default_email_category: product_education
15
+ data:
16
+ owner: lifecycle_team
17
+ steps:
18
+ - position: 1
19
+ name: Welcome
20
+ kind: email
21
+ delay_amount: 0
22
+ delay_unit: minutes
23
+ email_key: welcome
24
+ data:
25
+ purpose: activation
26
+ - position: 2
27
+ name: First project reminder
28
+ kind: email
29
+ delay_amount: 2
30
+ delay_unit: days
31
+ email_key: first_project_reminder
32
+ email_category: product_reminders
33
+ ```
34
+
35
+ ## Campaign fields
36
+
37
+ | Field | Required | Meaning |
38
+ | --- | --- | --- |
39
+ | `key` | Yes | Stable machine identifier used by enrollment calls |
40
+ | `name` | Yes | Operator-facing name |
41
+ | `description` | No | Operator-facing purpose and audience |
42
+ | `allow_reenrollment` | No | Whether a recipient with a closed enrollment may enter again; defaults to false |
43
+ | `default_email_category` | No | Default preference category for steps |
44
+ | `data` | No | Non-executable campaign metadata |
45
+ | `steps` | Yes | Non-empty ordered list of executable steps |
46
+
47
+ ## Step fields
48
+
49
+ | Field | Required | Meaning |
50
+ | --- | --- | --- |
51
+ | `position` | Yes | Unique positive integer within the version |
52
+ | `name` | Yes | Operator-facing step name |
53
+ | `kind` | No | Currently only `email`; defaults to `email` |
54
+ | `delay_amount` | Yes | Non-negative integer added after the previous step |
55
+ | `delay_unit` | Yes | `minutes`, `hours`, or `days` |
56
+ | `email_key` | Yes | Key registered by the host initializer |
57
+ | `email_category` | No | Step-specific category overriding the campaign default |
58
+ | `data` | No | Step metadata copied into the version |
59
+
60
+ Delays are cumulative. In the example, welcome is immediate and the reminder is two days later.
61
+
62
+ ## Validation and import
63
+
64
+ ```bash
65
+ # Validate syntax, schema, and registered mailer keys.
66
+ bin/rails rails_drips:validate DEFINITION=config/rails_drips/onboarding.yml
67
+
68
+ # Preview without writing.
69
+ bin/rails rails_drips:import DEFINITION=config/rails_drips/onboarding.yml
70
+
71
+ # Apply.
72
+ bin/rails rails_drips:import DEFINITION=config/rails_drips/onboarding.yml APPLY=true
73
+ ```
74
+
75
+ The importer returns outcomes such as `created`, `unchanged`, `metadata_updated`, or `version_created`. Each campaign imports transactionally.
76
+
77
+ ## What creates a version?
78
+
79
+ Changing executable step fields creates a complete new draft. Active and retired versions are never edited. Executable fields include position, name, kind, delay, email key, category, and step data.
80
+
81
+ Changing safe campaign metadata—name, description, reenrollment setting, default category, or campaign data—updates the campaign without creating a version.
82
+
83
+ Activate a newly imported version explicitly:
84
+
85
+ ```bash
86
+ bin/rails rails_drips:activate CAMPAIGN=onboarding VERSION=2
87
+ ```
88
+
89
+ New enrollments use version 2. Existing enrollments continue using the version they entered with.
90
+
91
+ ## Recommended workflow
92
+
93
+ 1. Register and test every new mailer key in the host.
94
+ 2. Change YAML in a reviewed pull request.
95
+ 3. Validate in CI.
96
+ 4. Dry-run in the target environment.
97
+ 5. Apply the import.
98
+ 6. Inspect the draft version and steps.
99
+ 7. Activate deliberately.
100
+ 8. Monitor scheduled delivery counts and failures.
@@ -0,0 +1,170 @@
1
+ # Getting started
2
+
3
+ This guide takes a host application from installation to its first scheduled drip.
4
+
5
+ ## 1. Install the engine
6
+
7
+ Add RailsDrips to the host application's Gemfile and run:
8
+
9
+ ```bash
10
+ bundle install
11
+ bin/rails generate rails_drips:install
12
+ bin/rails db:migrate
13
+ ```
14
+
15
+ The generator creates:
16
+
17
+ - `config/initializers/rails_drips.rb`
18
+ - `config/rails_drips/onboarding.yml`
19
+ - a timestamped RailsDrips migration
20
+
21
+ It does not run the migration. Re-running the generator uses normal Rails generator collision handling.
22
+
23
+ RailsDrips uses the host application's database, Active Job adapter, Action Mailer configuration, logger, and time zone.
24
+
25
+ ## 2. Create the email in the host application
26
+
27
+ RailsDrips never owns product email copy or templates. Create a regular Action Mailer class:
28
+
29
+ ```ruby
30
+ # app/mailers/onboarding_mailer.rb
31
+ class OnboardingMailer < ApplicationMailer
32
+ def welcome(delivery)
33
+ @delivery = delivery
34
+ @enrollment = delivery.enrollment
35
+ @recipient = @enrollment.recipient
36
+
37
+ mail(to: delivery.recipient_email, subject: "Welcome to Example")
38
+ end
39
+ end
40
+ ```
41
+
42
+ Add its normal templates:
43
+
44
+ ```erb
45
+ <%# app/views/onboarding_mailer/welcome.html.erb %>
46
+ <h1>Welcome, <%= @recipient.name %></h1>
47
+ ```
48
+
49
+ The mailer receives a persisted `RailsDrips::Delivery`. Useful values include:
50
+
51
+ - `delivery.recipient_email`
52
+ - `delivery.email_key` and `delivery.email_category`
53
+ - `delivery.enrollment.recipient`
54
+ - `delivery.enrollment.data_value`
55
+ - `delivery.enrollment.campaign`
56
+ - `delivery.step`
57
+
58
+ ## 3. Register the mailer key
59
+
60
+ Connect the YAML `email_key` to the host mailer in the generated initializer:
61
+
62
+ ```ruby
63
+ RailsDrips.configure do |config|
64
+ config.delivery_queue_name = :mailers
65
+
66
+ config.mailers.register "welcome", label: "Welcome email" do |delivery|
67
+ OnboardingMailer.welcome(delivery)
68
+ end
69
+ end
70
+ ```
71
+
72
+ The block must return an `ActionMailer::MessageDelivery`. Registration happens at application boot, so every key referenced by a definition must be registered before validating or importing that definition.
73
+
74
+ ## 4. Define and activate a campaign
75
+
76
+ Edit `config/rails_drips/onboarding.yml`:
77
+
78
+ ```yaml
79
+ schema_version: 1
80
+ campaign:
81
+ key: onboarding
82
+ name: Customer onboarding
83
+ description: Helps a new customer reach their first success.
84
+ allow_reenrollment: false
85
+ steps:
86
+ - position: 1
87
+ name: Welcome
88
+ kind: email
89
+ delay_amount: 0
90
+ delay_unit: minutes
91
+ email_key: welcome
92
+ ```
93
+
94
+ Validate and preview the database operation:
95
+
96
+ ```bash
97
+ bin/rails rails_drips:validate DEFINITION=config/rails_drips/onboarding.yml
98
+ bin/rails rails_drips:import DEFINITION=config/rails_drips/onboarding.yml
99
+ ```
100
+
101
+ Import writes only when `APPLY=true`:
102
+
103
+ ```bash
104
+ bin/rails rails_drips:import DEFINITION=config/rails_drips/onboarding.yml APPLY=true
105
+ bin/rails rails_drips:activate CAMPAIGN=onboarding VERSION=1
106
+ ```
107
+
108
+ Importing creates a draft. Activation is deliberately separate so a definition can be reviewed before it accepts enrollments.
109
+
110
+ ## 5. Enroll from a product event
111
+
112
+ Call RailsDrips from the host service, job, controller, or model callback that owns the product event:
113
+
114
+ ```ruby
115
+ result = RailsDrips.enroll(
116
+ campaign: "onboarding",
117
+ recipient: account,
118
+ source: "account_created",
119
+ data: { plan: account.plan }
120
+ )
121
+
122
+ case result.status
123
+ when :enrolled
124
+ Rails.logger.info("Enrolled ##{result.enrollment.id}")
125
+ when :already_open, :reenrollment_disallowed
126
+ # Expected idempotent outcomes.
127
+ when :campaign_inactive, :ineligible
128
+ # Expected business outcomes; result.reason may explain policy rejection.
129
+ end
130
+ ```
131
+
132
+ The recipient may be any persisted Active Record model. By default it must respond to `email_address` or `email`; configure a resolver if it does not.
133
+
134
+ Remove a recipient from future steps when the owning product event occurs:
135
+
136
+ ```ruby
137
+ RailsDrips.remove(
138
+ campaign: "onboarding",
139
+ recipient: account,
140
+ reason: "account_closed",
141
+ actor: Current.user
142
+ )
143
+ ```
144
+
145
+ Removal retains sent and failed evidence and cancels future scheduled deliveries.
146
+
147
+ ## 6. Run workers
148
+
149
+ RailsDrips uses only Active Job APIs. Configure and run the host application's chosen adapter—Solid Queue, GoodJob, Sidekiq, or another Active Job backend. RailsDrips does not start workers itself.
150
+
151
+ In development, verify the configured adapter actually processes the queue named by `delivery_queue_name`.
152
+
153
+ ## 7. Optional admin mount
154
+
155
+ Mount the engine in the host router:
156
+
157
+ ```ruby
158
+ # config/routes.rb
159
+ mount RailsDrips::Engine => "/drips"
160
+ ```
161
+
162
+ The current campaign index is then `/drips/campaigns`, but it returns `404` until the host explicitly enables and secures admin access. See [Operations](operations.md).
163
+
164
+ The current pages are an operational prototype. They are useful for inspecting engine data, but the finished admin design and demonstration app are tracked separately.
165
+
166
+ ## Next steps
167
+
168
+ - Add [host policies and transport integration](host-integration.md) if the product has eligibility or unsubscribe rules.
169
+ - Read [campaign definitions](campaign-definitions.md) before changing an active campaign.
170
+ - Set up [operational access and auditing](operations.md) before production use.
@@ -0,0 +1,160 @@
1
+ # Host application integration
2
+
3
+ RailsDrips is intentionally not a complete email product by itself. It provides the reusable state machine and delivery machinery while the host application keeps control of product behavior.
4
+
5
+ ## What the host must implement
6
+
7
+ Only three integrations are required for a basic campaign:
8
+
9
+ 1. An Action Mailer method and templates for every registered `email_key`.
10
+ 2. A registration block connecting each key to its mailer method.
11
+ 3. A call to `RailsDrips.enroll` from the product event that begins the journey.
12
+
13
+ The host must also run an Active Job worker where scheduled delivery should occur. Everything else in this guide is optional.
14
+
15
+ ## Integration map
16
+
17
+ | Host concern | Required? | Code location | RailsDrips calls it when |
18
+ | --- | --- | --- | --- |
19
+ | Mailer and templates | Yes | `app/mailers`, `app/views` | A scheduled delivery is ready to render |
20
+ | Mail registry | Yes | RailsDrips initializer | A delivery resolves its `email_key` |
21
+ | Enrollment trigger | Yes | Host domain service/job/controller | The host decides a recipient should enter a campaign |
22
+ | Active Job backend | Production | Host environment configuration | A delivery is scheduled or retried |
23
+ | Recipient email resolver | For nonstandard models | RailsDrips initializer | Enrollment snapshots the destination address |
24
+ | Eligibility evaluator | Optional | Initializer or host policy object | Enrollment and immediately before delivery |
25
+ | Preference evaluator | Optional | Initializer or host policy object | Immediately before delivery |
26
+ | Transport metadata hook | Optional | RailsDrips initializer | After rendering and before transport |
27
+ | Admin security adapters | For admin only | RailsDrips initializer | Every engine admin request |
28
+ | Queue inventory | Optional | Host operations adapter | An integrity audit requests queue evidence |
29
+
30
+ ## Mail registration
31
+
32
+ ```ruby
33
+ config.mailers.register "trial_day_1", label: "Trial: getting started" do |delivery|
34
+ TrialMailer.getting_started(delivery)
35
+ end
36
+ ```
37
+
38
+ The registry block receives the engine delivery and must return an `ActionMailer::MessageDelivery`. Keep selection logic in the registry or host mailer; do not put host-specific mailer constants in the gem.
39
+
40
+ Mailer keys are executable definition identifiers. Renaming a key creates a new campaign version.
41
+
42
+ ## Recipient integration
43
+
44
+ Enrollments use a polymorphic `recipient`; the engine does not require a `User` class. The default email resolver tries `recipient.email_address`, then `recipient.email`.
45
+
46
+ ```ruby
47
+ config.recipient_email_resolver = ->(recipient) do
48
+ recipient.primary_contact.email
49
+ end
50
+
51
+ config.recipient_label_resolver = ->(recipient) do
52
+ "#{recipient.company_name} (#{recipient.email})"
53
+ end
54
+ ```
55
+
56
+ The resolved email is copied to the delivery when the enrollment is scheduled. This preserves evidence even if the recipient changes its email later.
57
+
58
+ ## Eligibility
59
+
60
+ The eligibility evaluator defaults to allowing delivery. It runs at enrollment and again immediately before delivery:
61
+
62
+ ```ruby
63
+ config.eligibility_evaluator = lambda do |recipient:, campaign:, phase:, **context|
64
+ decision = CampaignEligibility.call(
65
+ recipient: recipient,
66
+ campaign_key: campaign.key,
67
+ phase: phase,
68
+ delivery: context[:delivery]
69
+ )
70
+
71
+ { eligible: decision.allowed?, reason: decision.reason }
72
+ end
73
+ ```
74
+
75
+ - `phase: :enrollment` runs before enrollment rows are written.
76
+ - `phase: :delivery` runs before rendering and sending each message.
77
+
78
+ Return `true`/`false`, or a hash with `eligible` or `allowed` plus an optional `reason`. Business rejection returns a result or cancellation state; policy exceptions remain real failures.
79
+
80
+ `force: true` bypasses eligibility and preference checks. The choice is persisted for auditability; reserve it for explicit operator or migration workflows.
81
+
82
+ ## Email preferences
83
+
84
+ The preference evaluator also defaults to allowing delivery:
85
+
86
+ ```ruby
87
+ config.preference_evaluator = lambda do |recipient:, category:, delivery:|
88
+ category.blank? || recipient.subscribed_to_email_category?(category)
89
+ end
90
+ ```
91
+
92
+ Set `default_email_category` on a campaign or `email_category` on a step. The step wins. RailsDrips stores the resolved category but does not define categories or unsubscribe pages—the host owns those concepts.
93
+
94
+ ## Transport metadata
95
+
96
+ Decorate the final `Mail::Message` before delivery:
97
+
98
+ ```ruby
99
+ config.transport_metadata_hook = lambda do |message:, delivery:|
100
+ message.header["X-Campaign-Key"] = delivery.campaign_key
101
+ message.header["X-RailsDrips-Delivery-ID"] = delivery.id.to_s
102
+ message
103
+ end
104
+ ```
105
+
106
+ Use this for provider tags, correlation IDs, or custom headers. Return the message that should be snapshotted and delivered. Provider SDKs and credentials remain host concerns.
107
+
108
+ ## Product lifecycle calls
109
+
110
+ RailsDrips does not guess when someone enters or leaves a campaign. Put calls at the host's domain boundary:
111
+
112
+ ```ruby
113
+ class Accounts::Activate
114
+ def call(account)
115
+ # Host activation work...
116
+ RailsDrips.enroll(campaign: "onboarding", recipient: account, source: "activation")
117
+ end
118
+ end
119
+ ```
120
+
121
+ The public operations are:
122
+
123
+ ```ruby
124
+ RailsDrips.enroll(...)
125
+ RailsDrips.remove(...)
126
+ RailsDrips.pause_campaign(campaign: campaign)
127
+ RailsDrips.resume_campaign(campaign: campaign)
128
+ RailsDrips.archive_campaign(campaign: campaign, reason: "retired")
129
+ RailsDrips.unarchive_campaign(campaign: campaign)
130
+ RailsDrips.enqueue_due_deliveries(campaign: campaign, dry_run: true)
131
+ ```
132
+
133
+ Operations return a `RailsDrips::Result` with a stable `status` and relevant `campaign`, `enrollment`, `delivery`, `reason`, or `count` values.
134
+
135
+ ## Reloading registrations in development
136
+
137
+ The registry rejects duplicate keys. If the host uses a Rails reloader hook, clear and rebuild it inside that hook:
138
+
139
+ ```ruby
140
+ Rails.application.config.to_prepare do
141
+ RailsDrips.configuration.mailers.clear
142
+ RailsDrips.configuration.mailers.register("welcome", label: "Welcome") do |delivery|
143
+ OnboardingMailer.welcome(delivery)
144
+ end
145
+ end
146
+ ```
147
+
148
+ Use either a boot-time initializer or `to_prepare`; do not register the same key through both paths.
149
+
150
+ ## Host integration tests
151
+
152
+ At minimum, cover:
153
+
154
+ - every registered key returns a renderable `ActionMailer::MessageDelivery`;
155
+ - lifecycle events enroll and remove the intended recipient;
156
+ - enrollment and delivery eligibility decisions;
157
+ - unsubscribe/category rejection;
158
+ - metadata on the final message;
159
+ - the chosen Active Job adapter executes scheduled jobs;
160
+ - admin routes fail closed and authorized operators can access them, if mounted.