DripDrop

Hex.pmHex DocsLicense

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

Why DripDrop?

Architecture

DripDrop owns the dripdrop Postgres schema through EctoEvolver 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:

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

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:

def deps do
  [
    {:dripdrop, "~> 0.1.0"}
  ]
end

Then fetch dependencies:

mix deps.get

For the full host-app setup, including scheduler migrations, runtime configuration, schema checks, and webhook mounting, see 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. This README keeps the main runtime shape visible:

# 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]
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:

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:

import DripDrop.Web.Router

scope "/" do
  dripdrop_webhooks("/webhooks/dripdrop")
end

Author and Run a Sequence

# 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"].

{: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.

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:

# 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:

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:

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:

mix test

Integration tests exercise the real PgFlow scheduler and are excluded from the default test run:

mix test --only integration

Quality gates used by this repo:

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/. From the repo root, run it with the local Hivemind wrapper:

bin/dripdrop start
bin/dripdrop stop
bin/dripdrop console

The wrapper runs Procfile.dev, starts Docker Postgres, and serves the demo at 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:

Changelog

Unreleased

License

MIT