# DripDrop
[](https://hex.pm/packages/dripdrop)
[](https://hexdocs.pm/dripdrop)
[](https://github.com/agoodway/dripdrop/blob/main/LICENSE)
> Drop-in sequential messaging for Elixir.
A backend-first, database-driven messaging sequence engine for Elixir. Drip onboarding, lifecycle nurture, win-back, and outbound campaigns across email, SMS, webhooks, Telegram, and other channels, with Elixir, HTTP, and predicate hooks for decision routing. Outbound mode adds sender pools, per-mailbox ramp schedules, auto-pause on reply, and message threading. Every sequence, enrollment, and delivery event lives in Postgres. Dispatch schedules through [PgFlow](https://github.com/agoodway/pgflow) by default, with Oban available for hosts that already run it.
DripDrop is for sequence/drip messaging — onboarding flows, lifecycle nurtures,
win-back campaigns, and optional cold outbound drip. Not for one-off
transactional email like password resets.
## What It Does
- Author versioned sequences with steps, timing, transitions, and conditions.
- Enroll subscribers into active sequence versions.
- Dispatch due steps through PgFlow (default) or Oban.
- Render templates with Liquid/Liquex, trusted EEx module templates, optional MJML email compilation, and opt-in deterministic spintax.
- Evaluate conditions through Predicated, enrollment data, events, Elixir hooks, and HTTP hooks.
- Send through database-stored channel adapters with encrypted credentials.
- Apply suppressions, quiet hours, rate limits, bounce/complaint thresholds, optional unsubscribe headers, and explicit sending rules.
- Normalize inbound provider webhooks into `message_events`.
- Run cold outbound versions through tenant-scoped sender pools with enrollment-time adapter pinning, ramp caps, min-gap checks, threading headers, and host-fed reply ingestion.
- Rewrite eligible links through GoodAnalytics, module, webhook, or no-op short-link providers.
## Why DripDrop?
- **Postgres is the source of truth** — sequences, enrollments, executions, suppressions, and message events are queryable SQL tables. Debug with `SELECT * FROM dripdrop.enrollments`.
- **No infrastructure beyond Postgres** — PgFlow runs the scheduler in your database. No Redis, no external queue.
- **Multi-tenant by default** — every domain table carries `tenant_key`. Query helpers require an explicit tenant scope (use `tenant_key: nil` for global records).
- **Provider-agnostic channels** — eight email providers, two SMS providers, plus Slack/Telegram/WhatsApp/Webhook/PubSub built in. Custom providers register through a small behaviour.
- **Encrypted credentials at rest** — channel adapter credentials are encrypted via Cloak with a host-supplied `DRIPDROP_ENCRYPTION_KEY`.
## Architecture
DripDrop owns the `dripdrop` Postgres schema through [EctoEvolver](https://github.com/agoodway/ecto_evolver) raw SQL migrations. When PgFlow is used as the scheduler, PgFlow owns its separate `pgflow` schema; DripDrop never writes PgFlow internals directly.
Current `dripdrop` tables:
- `sequences`, `sequence_versions`, `steps`, `step_transitions`, `conditions`
- `channel_adapters`, `http_hooks`
- `enrollments`, `step_executions`, `events`
- `suppressions`, `message_events`, `short_links`
- `adapter_pools`, `adapter_pool_members`, `adapter_sequence_budgets`
Lifecycle sequences use the existing adapter-selection chain: explicit step
adapter, step or sequence rotation, tenant default, then global default. Cold
outbound sequences opt into `sequence_versions.mode = "outbound"` and resolve
senders through an adapter pool once at enrollment time. The selected adapter is
pinned on `enrollments.adapter_id`, and outbound-only gates enforce health,
ramp caps, per-sequence sub-caps, min-gap timing, and email threading headers.
Lifecycle rows leave the new fields unset and keep the foundation dispatch flow.
Tenant scoping is represented by `tenant_key`. Query helpers that could leak tenant data require an explicit tenant scope; pass `tenant_key: nil` when intentionally querying global records. Deprecated unscoped helpers raise.
## Prerequisites
- Elixir 1.17+ / OTP 26+
- PostgreSQL 18+ (for native `uuidv7()` used by the v01 schema's UUIDv7 primary keys)
- A host Ecto repo
- A durable scheduler — PgFlow (recommended) or Oban
- `DRIPDROP_ENCRYPTION_KEY` set to a base64-encoded 32-byte key
Runtime dependencies: Ecto, Ecto SQL, Postgrex, EctoEvolver, Cloak Ecto, Req, Jason, Plug, Floki, Liquex, Nebulex local cache, Predicated, ex_phone_number, ex_email, Standard Webhooks, and PgFlow when used as the scheduler. Optional channel/provider integrations (Swoosh/Finch, MJML, Phoenix PubSub, Oban, AWS SNS, Telegram, WhatsApp SDK) are loaded only when the matching provider is used.
## Installation
Add `dripdrop` to your dependencies in `mix.exs`:
```elixir
def deps do
[
{:dripdrop, "~> 0.1.0"}
]
end
```
Then fetch dependencies:
```bash
mix deps.get
```
For the full host-app setup, including scheduler migrations, runtime
configuration, schema checks, and webhook mounting, see
[`guides/installation.md`](guides/installation.md).
## Quick Start
The minimum host-app setup is:
1. Configure DripDrop with your Ecto repo, scheduler, and channel settings.
2. Generate PgFlow migrations first, then the DripDrop wrapper migration.
3. Run `mix ecto.migrate`.
4. Call `DripDrop.startup_check/0` during boot after the repo and scheduler are started.
5. Mount provider webhooks if you use webhook-delivering providers.
The canonical installation walkthrough lives in
[`guides/installation.md`](guides/installation.md). This README keeps the main
runtime shape visible:
```elixir
# config/config.exs
config :dripdrop,
repo: MyApp.Repo,
scheduler: DripDrop.Schedulers.Pgflow,
channels: [],
quiet_hours_default: {8, 21},
sms_max_chars: 1600
config :dripdrop, :pgflow,
jobs: [DripDrop.Jobs.DispatchStep, DripDrop.Jobs.CronTick]
```
```bash
mix pgflow.gen.postgres_extensions_migration # add --no-cron if pg_cron unavailable
mix pgflow.gen.pgmq_migration
mix pgflow.setup
mix pgflow.gen.job_migration DripDrop.Jobs.DispatchStep
mix pgflow.gen.job_migration DripDrop.Jobs.CronTick
# DripDrop schema
mix dripdrop.setup --repo MyApp.Repo
# Apply everything
mix ecto.migrate
```
These PgFlow job migrations install DripDrop's generic scheduler workers once.
Sequence authoring remains dynamic: new DripDrop sequences, steps, transitions,
conditions, hooks, and enrollments do not require new PgFlow migrations.
Set `DRIPDROP_ENCRYPTION_KEY` to a base64-encoded 32-byte key before boot.
Call `DripDrop.startup_check/0` in your host `Application.start/2` callback
after the Repo, scheduler supervisor, and channel registrations are configured:
```elixir
def start(_type, _args) do
children = [MyApp.Repo, ...]
with {:ok, sup} <- Supervisor.start_link(children, strategy: :one_for_one),
:ok <- DripDrop.startup_check() do
{:ok, sup}
end
end
```
Mount provider webhooks in a Phoenix router when needed:
```elixir
import DripDrop.Web.Router
scope "/" do
dripdrop_webhooks("/webhooks/dripdrop")
end
```
### Author and Run a Sequence
```elixir
# Create a channel adapter (credentials are encrypted at rest)
{:ok, adapter} = DripDrop.create_channel_adapter(%{
channel: "email",
provider: "postmark",
name: "Default Postmark",
is_default: true,
credentials: %{api_token: System.fetch_env!("POSTMARK_API_TOKEN")},
tenant_key: nil
})
# Author a sequence and version
{:ok, sequence} = DripDrop.create_sequence(%{key: "welcome", name: "Welcome Series"})
{:ok, version} = DripDrop.create_sequence_version(sequence.id, %{version: 1})
{:ok, _step} = DripDrop.create_step(version.id, %{
key: "day_1",
channel: "email",
template: %{subject: "Welcome!", html: "<p>Hi {{ subscriber.first_name }}</p>"},
delay: %{hours: 0}
})
# Activate (archives the previously active version)
{:ok, _} = DripDrop.activate_sequence_version(version.id)
# Enroll a subscriber
{:ok, _enrollment} = DripDrop.enroll(%{
sequence_id: sequence.id,
subscriber_type: "user",
subscriber_id: "user_123",
data: %{first_name: "Sam", email: "sam@example.com"},
tenant_key: nil
})
```
### Cold Outbound Mode
Cold outbound is opt-in per sequence version. Lifecycle behavior stays the
default. To send a prospect drip from the same mailbox across every step, create
an adapter pool, add mailbox or ESP members, and set the sequence version to
`mode: :outbound` with `config["pool_id"]`.
```elixir
{:ok, pool} =
DripDrop.create_adapter_pool(%{
tenant_key: "acct_123",
name: "sales_pool",
on_pin_unavailable: :pause
})
{:ok, _member} =
DripDrop.add_pool_member(pool.id, %{
adapter_id: adapter.id,
class: :mailbox,
weight: 1
})
{:ok, version} =
DripDrop.create_sequence_version(sequence.id, %{
version: 2,
mode: :outbound,
config: %{"pool_id" => pool.id}
})
```
Outbound enrollments store the selected sender in `enrollments.adapter_id`.
Follow-up email steps generate `Message-ID`, `In-Reply-To`, and `References`
headers for threading. Hosts that receive replies through IMAP, Microsoft Graph,
or Gmail API watch should normalize those messages and call
`DripDrop.ingest_inbound_message/2`. See
[`guides/cold_outbound.md`](guides/cold_outbound.md).
## Channels
Built-in channel providers:
| Channel | Providers |
|----------|-----------------------------------------------------------------|
| Email | Mailgun, SendGrid, Postmark, MailerSend, SES, SMTP, Gmail, MS365 |
| SMS | Twilio, AWS SNS |
| Webhook | Standard Webhooks-shaped outbound requests |
| PubSub | Phoenix PubSub |
| Slack | Incoming webhook |
| Telegram | Bot API |
| WhatsApp | Cloud API |
Custom providers register with `DripDrop.Channels.register/3`. See `guides/extending.md`.
Gmail and Microsoft 365 do not own OAuth flows. The host provides a `token_callback` MFA that returns access tokens; DripDrop never stores refresh tokens or OAuth client secrets. See `guides/oauth_providers.md`.
## Public API
Common entry points exposed on the `DripDrop` module:
```elixir
# Sequence authoring
DripDrop.create_sequence(attrs)
DripDrop.create_sequence_version(sequence_id, attrs)
DripDrop.activate_sequence_version(version_id)
DripDrop.create_step(version_id, attrs)
DripDrop.create_step_transition(version_id, attrs)
DripDrop.create_condition(owner_id, attrs)
DripDrop.validate_sequence_version(version_id)
# Channel adapters
DripDrop.create_channel_adapter(attrs)
DripDrop.update_channel_adapter(adapter, attrs)
DripDrop.list_channel_adapters(%{tenant_key: tenant_key})
DripDrop.get_default_adapter(channel, tenant_key)
# HTTP hooks
DripDrop.create_http_hook(sequence_id, attrs)
DripDrop.update_http_hook(hook, attrs)
DripDrop.test_http_hook(hook_id, data)
DripDrop.list_http_hooks(sequence_id, tenant_key)
# Enrollments
DripDrop.enroll(attrs)
DripDrop.unenroll(enrollment_id, tenant_key)
DripDrop.pause_enrollment(enrollment_id, tenant_key)
DripDrop.resume_enrollment(enrollment_id, tenant_key)
DripDrop.track_event(identity, event_key, event_data)
DripDrop.list_active_enrollments(%{tenant_key: tenant_key})
DripDrop.get_enrollment(sequence_id, subscriber_type, subscriber_id, tenant_key)
# Operations
DripDrop.suppress(attrs)
DripDrop.replay(step_execution_id)
DripDrop.webhook_routes()
DripDrop.startup_check()
# Cold outbound (optional)
DripDrop.create_adapter_pool(attrs)
DripDrop.update_adapter_pool(pool, attrs)
DripDrop.delete_adapter_pool(pool_id, opts)
DripDrop.list_adapter_pools(%{tenant_key: tenant_key})
DripDrop.add_pool_member(pool_id, attrs)
DripDrop.remove_pool_member(member_id, tenant_key)
DripDrop.list_pool_members(pool_id)
DripDrop.set_adapter_health(adapter_id, attrs)
DripDrop.set_adapter_sequence_budget(adapter_id, sequence_version_id, attrs)
DripDrop.repin_enrollment(enrollment_id, adapter_id, opts)
DripDrop.ingest_inbound_message(adapter_id_or_scope, normalized_message)
```
Deprecated unscoped helpers raise — pass an explicit `tenant_key` (use `nil` for global records).
## Short Links
Short-link rewriting runs after rendering and before delivery. It parses HTML with Floki, rewrites only `href` and `src`, preserves plain-text punctuation, skips sensitive/already-short links, and persists idempotent `short_links` rows.
Built-in short-link providers:
- `DripDrop.ShortLinks.GoodAnalytics`
- `DripDrop.ShortLinks.Module`
- `DripDrop.ShortLinks.Webhook`
- `DripDrop.ShortLinks.None`
Configure globally, per tenant, sequence, or step — step config wins. See `guides/short_links.md`.
## Mix Tasks
| Task | Description |
|-------------------------------|------------------------------------------------------|
| `mix dripdrop.setup` | Generate the wrapper migration into the host app |
| `mix dripdrop.gen.migration` | Generate a follow-up migration |
| `mix dripdrop.check_schema` | Verify the installed schema version (CI/deploy gate) |
| `mix dripdrop.uninstall` | Generate a teardown migration |
## Testing
DripDrop ships with a Docker Compose setup for the development database:
```bash
docker compose up -d
```
This starts Postgres 18 with `pg_cron` configured against `dripdrop_dev` on `localhost:54325` (user: `postgres`, password: `postgres`).
Run the test suite:
```bash
mix test
```
Integration tests exercise the real PgFlow scheduler and are excluded from the
default test run:
```bash
mix test --only integration
```
Quality gates used by this repo:
```bash
mix quality # compile --warnings-as-errors, format check, sobelow, doctor, credo --strict
mix dialyzer
```
CI runs the suite under Postgres 18 both with and without `pg_cron`.
## Demo App
The demo app lives in [`demo/`](demo/README.md). From the repo root, run it with
the local Hivemind wrapper:
```bash
bin/dripdrop start
bin/dripdrop stop
bin/dripdrop console
```
The wrapper runs `Procfile.dev`, starts Docker Postgres, and serves the demo at
[`localhost:4012`](http://localhost:4012). The demo includes onboarding, lead
nurture, and cold outbound scenarios using local/sandboxed channels, PubSub, and
mock HTTP hooks.
## Guides
In-depth documentation lives in the project guides:
- [`installation.md`](guides/installation.md) — full installation reference
- [`sending_rules.md`](guides/sending_rules.md) — suppressions, rate limits, thresholds
- [`lifecycle_email.md`](guides/lifecycle_email.md) — email templates, MJML, unsubscribe headers
- [`quiet_hours.md`](guides/quiet_hours.md) — per-tenant quiet hours
- [`short_links.md`](guides/short_links.md) — link rewriting and providers
- [`oauth_providers.md`](guides/oauth_providers.md) — Gmail and MS365 token callbacks
- [`cold_outbound.md`](guides/cold_outbound.md) — sender pools, ramping, threading, inbound replies
- [`operations.md`](guides/operations.md) — replay, suppression, observability
- [`extending.md`](guides/extending.md) — custom channels and short-link adapters
## Changelog
### Unreleased
- Added optional cold outbound mode with adapter pools, enrollment-time sender
pinning, adapter health/ramp controls, min-gap enforcement, per-sequence
sub-caps, outbound Message-ID threading, host-callable inbound reply
ingestion, and deterministic spintax.
- Updated the initial V01 schema to include cold outbound tables and columns.
DripDrop is still pre-production, so no separate V02 migration is maintained.
## License
MIT