Current section
Files
Jump to
Current section
Files
lib/listmonk/subscribers.ex
defmodule Listmonk.Subscribers do
@moduledoc """
Functions for managing Listmonk subscribers.
"""
alias Listmonk.{Client, Config, Error}
alias Listmonk.Models.Subscriber
@doc """
Retrieves a list of subscribers based on optional filters.
## Options
- `:query` - SQL query string for filtering (e.g., "subscribers.attribs->>'city' = 'Portland'")
- `:list_id` - Filter by list ID
- `:page` - Page number (default: 1)
- `:per_page` - Results per page (default: 100)
## Examples
iex> Listmonk.Subscribers.get()
{:ok, [%Subscriber{}, ...]}
iex> Listmonk.Subscribers.get(query: "subscribers.email LIKE '%@example.com'")
{:ok, [%Subscriber{}, ...]}
"""
@spec get(keyword(), Config.t() | nil) :: {:ok, list(Subscriber.t())} | {:error, Error.t()}
def get(opts \\ [], config \\ nil) do
query_params = build_query_params(opts)
path = "/api/subscribers?#{URI.encode_query(query_params)}"
case fetch_all_pages(path, config, opts) do
{:ok, results} -> {:ok, Enum.map(results, &Subscriber.from_api/1)}
error -> error
end
end
@doc """
Retrieves subscribers. Raises on error.
"""
@spec get!(keyword(), Config.t() | nil) :: list(Subscriber.t())
def get!(opts \\ [], config \\ nil) do
case get(opts, config) do
{:ok, subscribers} -> subscribers
{:error, error} -> raise error
end
end
@doc """
Retrieves a subscriber by email address.
## Examples
iex> Listmonk.Subscribers.get_by_email("user@example.com")
{:ok, %Subscriber{}}
iex> Listmonk.Subscribers.get_by_email("nonexistent@example.com")
{:ok, nil}
"""
@spec get_by_email(String.t(), Config.t() | nil) ::
{:ok, Subscriber.t() | nil} | {:error, Error.t()}
def get_by_email(email, config \\ nil) do
encoded_email = email |> String.replace("+", "%2b")
query = "subscribers.email='#{encoded_email}'"
case get([query: query, per_page: 1], config) do
{:ok, [subscriber | _]} -> {:ok, subscriber}
{:ok, []} -> {:ok, nil}
error -> error
end
end
@doc """
Retrieves a subscriber by email. Raises on error.
"""
@spec get_by_email!(String.t(), Config.t() | nil) :: Subscriber.t() | nil
def get_by_email!(email, config \\ nil) do
case get_by_email(email, config) do
{:ok, subscriber} -> subscriber
{:error, error} -> raise error
end
end
@doc """
Retrieves a subscriber by ID.
## Examples
iex> Listmonk.Subscribers.get_by_id(123)
{:ok, %Subscriber{}}
"""
@spec get_by_id(integer(), Config.t() | nil) ::
{:ok, Subscriber.t() | nil} | {:error, Error.t()}
def get_by_id(id, config \\ nil) do
query = "subscribers.id=#{id}"
case get([query: query, per_page: 1], config) do
{:ok, [subscriber | _]} -> {:ok, subscriber}
{:ok, []} -> {:ok, nil}
error -> error
end
end
@doc """
Retrieves a subscriber by ID. Raises on error.
"""
@spec get_by_id!(integer(), Config.t() | nil) :: Subscriber.t() | nil
def get_by_id!(id, config \\ nil) do
case get_by_id(id, config) do
{:ok, subscriber} -> subscriber
{:error, error} -> raise error
end
end
@doc """
Retrieves a subscriber by UUID.
## Examples
iex> Listmonk.Subscribers.get_by_uuid("c37786af-e6ab-4260-9b49-740adpcm6ed")
{:ok, %Subscriber{}}
"""
@spec get_by_uuid(String.t(), Config.t() | nil) ::
{:ok, Subscriber.t() | nil} | {:error, Error.t()}
def get_by_uuid(uuid, config \\ nil) do
query = "subscribers.uuid='#{uuid}'"
case get([query: query, per_page: 1], config) do
{:ok, [subscriber | _]} -> {:ok, subscriber}
{:ok, []} -> {:ok, nil}
error -> error
end
end
@doc """
Retrieves a subscriber by UUID. Raises on error.
"""
@spec get_by_uuid!(String.t(), Config.t() | nil) :: Subscriber.t() | nil
def get_by_uuid!(uuid, config \\ nil) do
case get_by_uuid(uuid, config) do
{:ok, subscriber} -> subscriber
{:error, error} -> raise error
end
end
@doc """
Creates a new subscriber.
## Attributes
- `:email` (required) - Email address
- `:name` (required) - Full name
- `:lists` (required) - List of list IDs to subscribe to
- `:status` - Status (:enabled, :disabled, :blocklisted), default: :enabled
- `:preconfirm` - Skip confirmation for double opt-in lists, default: true
- `:attribs` - Map of custom attributes
## Examples
iex> Listmonk.Subscribers.create(%{
...> email: "user@example.com",
...> name: "John Doe",
...> lists: [1, 2],
...> attribs: %{"city" => "Portland"}
...> })
{:ok, %Subscriber{}}
"""
@spec create(map(), Config.t() | nil) :: {:ok, Subscriber.t()} | {:error, Error.t()}
def create(attrs, config \\ nil) do
with {:ok, payload} <- build_create_payload(attrs) do
case Client.post("/api/subscribers", config, json: payload) do
{:ok, %{"data" => data}} -> {:ok, Subscriber.from_api(data)}
error -> error
end
end
end
@doc """
Creates a new subscriber. Raises on error.
"""
@spec create!(map(), Config.t() | nil) :: Subscriber.t()
def create!(attrs, config \\ nil) do
case create(attrs, config) do
{:ok, subscriber} -> subscriber
{:error, error} -> raise error
end
end
@doc """
Updates a subscriber.
## Attributes
- `:name` - Update name
- `:email` - Update email
- `:status` - Update status
- `:attribs` - Update custom attributes
- `:add_lists` - List IDs to add
- `:remove_lists` - List IDs to remove
## Examples
iex> subscriber = Listmonk.Subscribers.get_by_id!(123)
iex> Listmonk.Subscribers.update(subscriber, %{name: "Jane Doe", add_lists: [3]})
{:ok, %Subscriber{}}
"""
@spec update(Subscriber.t(), map(), Config.t() | nil) ::
{:ok, Subscriber.t()} | {:error, Error.t()}
def update(%Subscriber{id: id} = subscriber, attrs, config \\ nil) do
updated_subscriber = merge_subscriber_attrs(subscriber, attrs)
payload =
Subscriber.to_api(updated_subscriber, list_ids: calculate_list_ids(subscriber, attrs))
case Client.put("/api/subscribers/#{id}", config, json: payload) do
{:ok, _response} -> get_by_id(id, config)
error -> error
end
end
@doc """
Updates a subscriber. Raises on error.
"""
@spec update!(Subscriber.t(), map(), Config.t() | nil) :: Subscriber.t()
def update!(subscriber, attrs, config \\ nil) do
case update(subscriber, attrs, config) do
{:ok, updated} -> updated
{:error, error} -> raise error
end
end
@doc """
Deletes a subscriber by email or ID.
## Examples
iex> Listmonk.Subscribers.delete("user@example.com")
{:ok, true}
iex> Listmonk.Subscribers.delete(123)
{:ok, true}
"""
@spec delete(String.t() | integer(), Config.t() | nil) :: {:ok, boolean()} | {:error, Error.t()}
def delete(identifier, config \\ nil)
def delete(email, config) when is_binary(email) do
case get_by_email(email, config) do
{:ok, nil} -> {:ok, false}
{:ok, %Subscriber{id: id}} -> delete(id, config)
error -> error
end
end
def delete(id, config) when is_integer(id) do
case Client.delete("/api/subscribers/#{id}", config) do
{:ok, %{"data" => result}} -> {:ok, result == true}
error -> error
end
end
@doc """
Deletes a subscriber. Raises on error.
"""
@spec delete!(String.t() | integer(), Config.t() | nil) :: boolean()
def delete!(identifier, config \\ nil) do
case delete(identifier, config) do
{:ok, result} -> result
{:error, error} -> raise error
end
end
@doc """
Enables a subscriber.
## Examples
iex> subscriber = Listmonk.Subscribers.get_by_id!(123)
iex> Listmonk.Subscribers.enable(subscriber)
{:ok, %Subscriber{status: :enabled}}
"""
@spec enable(Subscriber.t(), Config.t() | nil) :: {:ok, Subscriber.t()} | {:error, Error.t()}
def enable(subscriber, config \\ nil) do
update(subscriber, %{status: :enabled}, config)
end
@doc """
Enables a subscriber. Raises on error.
"""
@spec enable!(Subscriber.t(), Config.t() | nil) :: Subscriber.t()
def enable!(subscriber, config \\ nil) do
case enable(subscriber, config) do
{:ok, updated} -> updated
{:error, error} -> raise error
end
end
@doc """
Disables a subscriber.
## Examples
iex> subscriber = Listmonk.Subscribers.get_by_id!(123)
iex> Listmonk.Subscribers.disable(subscriber)
{:ok, %Subscriber{status: :disabled}}
"""
@spec disable(Subscriber.t(), Config.t() | nil) :: {:ok, Subscriber.t()} | {:error, Error.t()}
def disable(subscriber, config \\ nil) do
update(subscriber, %{status: :disabled}, config)
end
@doc """
Disables a subscriber. Raises on error.
"""
@spec disable!(Subscriber.t(), Config.t() | nil) :: Subscriber.t()
def disable!(subscriber, config \\ nil) do
case disable(subscriber, config) do
{:ok, updated} -> updated
{:error, error} -> raise error
end
end
@doc """
Blocks (unsubscribes) a subscriber.
## Examples
iex> subscriber = Listmonk.Subscribers.get_by_id!(123)
iex> Listmonk.Subscribers.block(subscriber)
{:ok, %Subscriber{status: :blocklisted}}
"""
@spec block(Subscriber.t(), Config.t() | nil) :: {:ok, Subscriber.t()} | {:error, Error.t()}
def block(subscriber, config \\ nil) do
update(subscriber, %{status: :blocklisted}, config)
end
@doc """
Blocks a subscriber. Raises on error.
"""
@spec block!(Subscriber.t(), Config.t() | nil) :: Subscriber.t()
def block!(subscriber, config \\ nil) do
case block(subscriber, config) do
{:ok, updated} -> updated
{:error, error} -> raise error
end
end
@doc """
Confirms opt-in for a subscriber to a list.
## Examples
iex> Listmonk.Subscribers.confirm_optin(subscriber_uuid, list_uuid)
{:ok, true}
"""
@spec confirm_optin(String.t(), String.t(), Config.t() | nil) ::
{:ok, boolean()} | {:error, Error.t()}
def confirm_optin(subscriber_uuid, list_uuid, config \\ nil) do
payload = %{"l" => list_uuid, "confirm" => "true"}
path = "/subscription/optin/#{subscriber_uuid}"
case Client.post(path, config, form: payload) do
{:ok, response} ->
body = response["data"] || response
success = check_optin_success(body)
{:ok, success}
error ->
error
end
end
@doc """
Confirms opt-in. Raises on error.
"""
@spec confirm_optin!(String.t(), String.t(), Config.t() | nil) :: boolean()
def confirm_optin!(subscriber_uuid, list_uuid, config \\ nil) do
case confirm_optin(subscriber_uuid, list_uuid, config) do
{:ok, result} -> result
{:error, error} -> raise error
end
end
# Private functions
defp build_query_params(opts) do
%{
page: Keyword.get(opts, :page, 1),
per_page: Keyword.get(opts, :per_page, 100),
order_by: "updated_at",
order: "DESC"
}
|> maybe_add_query(opts)
|> maybe_add_list_id(opts)
end
defp maybe_add_query(params, opts) do
case Keyword.get(opts, :query) do
nil -> params
query -> Map.put(params, :query, query)
end
end
defp maybe_add_list_id(params, opts) do
case Keyword.get(opts, :list_id) do
nil -> params
list_id -> Map.put(params, :list_id, list_id)
end
end
defp fetch_all_pages(initial_path, config, opts) do
per_page = Keyword.get(opts, :per_page, 100)
fetch_pages(initial_path, config, per_page, 1, [])
end
defp fetch_pages(path_template, config, per_page, page, acc) do
path = String.replace(path_template, ~r/page=\d+/, "page=#{page}")
case Client.get(path, config) do
{:ok, %{"data" => %{"results" => results, "total" => total}}} ->
new_acc = acc ++ results
retrieved = page * per_page
if retrieved < total do
fetch_pages(path_template, config, per_page, page + 1, new_acc)
else
{:ok, new_acc}
end
error ->
error
end
end
defp build_create_payload(attrs) do
email = Map.get(attrs, :email) |> validate_required(:email)
name = Map.get(attrs, :name) |> validate_required(:name)
lists = Map.get(attrs, :lists) |> validate_required(:lists)
payload = %{
"email" => String.trim(email),
"name" => String.trim(name),
"status" => status_to_string(Map.get(attrs, :status, :enabled)),
"lists" => lists,
"preconfirm_subscriptions" => Map.get(attrs, :preconfirm, true),
"attribs" => Map.get(attrs, :attribs, %{})
}
{:ok, payload}
rescue
e in ArgumentError -> {:error, Error.new(e.message)}
end
defp validate_required(nil, field), do: raise(ArgumentError, "#{field} is required")
defp validate_required(value, _field), do: value
defp status_to_string(:enabled), do: "enabled"
defp status_to_string(:disabled), do: "disabled"
defp status_to_string(:blocklisted), do: "blocklisted"
defp status_to_string(status) when is_binary(status), do: status
defp merge_subscriber_attrs(subscriber, attrs) do
%{
subscriber
| name: Map.get(attrs, :name, subscriber.name),
email: Map.get(attrs, :email, subscriber.email),
status: Map.get(attrs, :status, subscriber.status),
attribs: Map.get(attrs, :attribs, subscriber.attribs)
}
end
defp calculate_list_ids(subscriber, attrs) do
current_ids = Subscriber.to_api(subscriber, []) |> Map.get("lists", [])
add_lists = Map.get(attrs, :add_lists, []) |> MapSet.new()
remove_lists = Map.get(attrs, :remove_lists, []) |> MapSet.new()
current_ids
|> MapSet.new()
|> MapSet.union(add_lists)
|> MapSet.difference(remove_lists)
|> MapSet.to_list()
end
defp check_optin_success(body) when is_binary(body) do
success_phrases = [
"Subscribed successfully",
"Confirmed",
"no subscriptions to confirm",
"No subscriptions"
]
Enum.any?(success_phrases, &String.contains?(body, &1))
end
defp check_optin_success(_), do: false
end