DripDrop extension points are Elixir modules with small behaviours or registration APIs.

Custom Channel Provider

Implement DripDrop.Channel:

defmodule MyApp.Channels.Resend do
  @behaviour DripDrop.Channel

  def deliver(step, enrollment, adapter), do: {:ok, %{provider_message_id: "..."}}
  def validate_credentials(credentials), do: :ok
  def webhook_routes(_adapter), do: []
  def verify_signature(_adapter, _request), do: :ok
end

Register it at boot:

DripDrop.Channels.register(:email, :resend, MyApp.Channels.Resend)

Adding A New Email Provider

  1. Choose the delivery path: use a Swoosh adapter when one exists, otherwise call the provider API directly with Req.
  2. Implement validate_credentials/1 with cheap local validation and optional provider verification.
  3. Add verify_signature/2 and webhook_routes/1 only when the provider sends signed delivery webhooks.
  4. Register the provider with DripDrop.Channels.register/3.

Use DripDrop.Channels.Email.SwooshDelivery for Swoosh-backed providers so payload mapping and error taxonomy stay consistent.

Implement DripDrop.ShortLinks.Adapter:

defmodule MyApp.ShortLinks do
  @behaviour DripDrop.ShortLinks.Adapter

  alias DripDrop.ShortLinks.Result

  def create_link(request, opts) do
    {:ok,
     %Result{
       short_url: "https://s.example/abc",
       provider_id: request.idempotency_key,
       response: %{original_url: request.original_url, opts: opts}
     }}
  end
end

Custom Scheduler

Implement DripDrop.Scheduler when PgFlow or Oban is not the right runtime. The callbacks are schedule(execution, scheduled_for) and cancel(job_id). Schedulers should enqueue durable work from the StepExecution record rather than embedding rendered payloads.

Cold Outbound Extension Points

Pool Allocators

DripDrop currently ships one allocator: DripDrop.AdapterPools.WDRR. It stores deficit counters in ETS and reads pool membership, health, and cap data from the database. A future custom allocator should keep the same contract:

pick_member(pool, sequence_version) ::
  {:ok, %DripDrop.AdapterPoolMember{}} | {:error, :pool_exhausted}

Allocator output must be tenant-safe, return only active pool members, and leave existing enrollment pins untouched.

External Health Signals

Hosts that run inbox-placement checks, seed tests, or provider reputation monitors can feed structured results into DripDrop:

DripDrop.set_adapter_health(adapter.id, %{
  health_state: :resting,
  health_score: 0.42,
  source: :postmaster_tools
})

The call updates channel_adapters.health_state, emits health telemetry, and is used by outbound pool selection and dispatch gates. Lifecycle sequences ignore these fields.

Host Inbox Infrastructure

DripDrop does not ship an IMAP, Microsoft Graph, or Gmail poller. Hosts that already receive mailbox events should normalize the message and call:

DripDrop.ingest_inbound_message(adapter.id, %{
  message_id: "reply@example.net",
  in_reply_to: "019e...@example.com",
  references: ["019e...@example.com"],
  from: "prospect@example.org",
  to: "sales@example.com",
  body_text: "Interested",
  received_at: DateTime.utc_now(),
  intent: :reply
})

Correlation prefers in_reply_to against step_executions.out_message_id, then falls back to provider ids when supplied in headers.

Custom Hook Module

Sequence hooks call a host module through DripDrop.HookBehavior:

defmodule MyApp.DripDropHooks do
  @behaviour DripDrop.HookBehavior

  def handle_hook(:score_lead, enrollment, context) do
    {:ok, get_in(enrollment.data, ["score"]) || context[:default_score]}
  end
end

Choosing a Condition Type

Conditions on steps and transitions come in two evaluation flavors. They look similar but use different comparison semantics on purpose — pick by the shape of the rule you need to express, not by personal preference.

enrollment_data, hook, and event — coercive comparator

These types take a (field_path, operator, expected_value) triple. Operators match the Predicated DSL vocabulary: ==, !=, contains, in, >, >=, <, <=. The comparator coerces both sides with to_string/1 for the equality operators, and uses Float.parse/1 for the numeric ones, so expected_value: "5" matches enrollment data of integer 5 or string "5" identically.

Use this for the common case: one field, one operator, one expected value. Compose with transition.condition_mode = "all" or "any" when you need multiple conditions to fire together.

%{
  condition_type: "enrollment_data",
  field_path: "trial_days_remaining",
  operator: "<",
  expected_value: "3"
}

predicate — typed DSL

The predicate type stores a Predicated expression in config["predicate"]. It supports and, or, parentheses, and types are compared strictly (an integer field against a string literal will not match — quote string literals, leave numeric literals unquoted).

Use this when you need compound boolean logic with grouping, e.g. (A and B) or (C and D), that a single condition_mode can't express.

%{
  condition_type: "predicate",
  config: %{
    "predicate" =>
      "(plan == 'pro' and trial_days_remaining > 0) or has_paid_invoice == true"
  }
}

Why two evaluators

The two paths are kept deliberate: enrollment_data is forgiving for simple rules, predicate is precise for advanced authoring. They cannot be merged without changing observable behavior — a typed comparison would silently flip an expected_value: "5" rule today.