DripDrop
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
- 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 (usetenant_key: nilfor 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 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,conditionschannel_adapters,http_hooksenrollments,step_executions,eventssuppressions,message_events,short_linksadapter_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_KEYset 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:
def deps do
[
{:dripdrop, "~> 0.1.0"}
]
endThen 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:
- Configure DripDrop with your Ecto repo, scheduler, and channel settings.
- Generate PgFlow migrations first, then the DripDrop wrapper migration.
-
Run
mix ecto.migrate. -
Call
DripDrop.startup_check/0during boot after the repo and scheduler are started. - 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.migrateThese 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
endMount provider webhooks in a Phoenix router when needed:
import DripDrop.Web.Router
scope "/" do
dripdrop_webhooks("/webhooks/dripdrop")
endAuthor 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 |
|---|---|
| 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 |
| 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:
DripDrop.ShortLinks.GoodAnalyticsDripDrop.ShortLinks.ModuleDripDrop.ShortLinks.WebhookDripDrop.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:
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 testIntegration tests exercise the real PgFlow scheduler and are excluded from the default test run:
mix test --only integrationQuality 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:
installation.md— full installation referencesending_rules.md— suppressions, rate limits, thresholdslifecycle_email.md— email templates, MJML, unsubscribe headersquiet_hours.md— per-tenant quiet hoursshort_links.md— link rewriting and providersoauth_providers.md— Gmail and MS365 token callbackscold_outbound.md— sender pools, ramping, threading, inbound repliesoperations.md— replay, suppression, observabilityextending.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