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,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.
|