All checks were successful
deploy / deploy (push) Successful in 1m38s
URL-based offset pagination with ?page=N for bookmarkable pages. Admin views use push_patch, shop collection uses navigate links. Responsive on mobile with horizontal-scroll tables and stacking pagination controls. Includes dev seed script for testing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
343 lines
10 KiB
Elixir
343 lines
10 KiB
Elixir
defmodule Berrypod.Newsletter do
|
|
@moduledoc """
|
|
Newsletter subscriber management and campaign sending.
|
|
|
|
Privacy-first design: double opt-in, plain text only, no tracking pixels,
|
|
hashed confirmation tokens, minimal data collection (email only).
|
|
"""
|
|
|
|
import Ecto.Query
|
|
|
|
alias Berrypod.Newsletter.{Campaign, Subscriber}
|
|
alias Berrypod.{Repo, Settings}
|
|
|
|
# ── Subscriber flow ──────────────────────────────────────────────
|
|
|
|
@doc """
|
|
Subscribes an email address. Creates a pending subscriber and enqueues
|
|
a confirmation email.
|
|
|
|
Returns `{:ok, subscriber}` for new signups, `{:already_confirmed, subscriber}`
|
|
if the email is already confirmed, or `{:error, changeset}` on validation failure.
|
|
|
|
Options:
|
|
- `consent_text` — the exact wording shown at signup
|
|
- `source` — "website" (default) or "admin_import"
|
|
- `ip_hash` — hashed IP for consent proof
|
|
"""
|
|
def subscribe(email, opts \\ []) do
|
|
email = email |> String.trim() |> String.downcase()
|
|
|
|
case Repo.get_by(Subscriber, email: email) do
|
|
%Subscriber{status: "confirmed"} = sub ->
|
|
{:already_confirmed, sub}
|
|
|
|
%Subscriber{status: "unsubscribed"} = sub ->
|
|
# Re-subscribe: reset to pending with new token
|
|
resubscribe(sub, opts)
|
|
|
|
%Subscriber{status: "pending"} = sub ->
|
|
# Already pending — resend confirmation
|
|
enqueue_confirmation(sub)
|
|
{:ok, sub}
|
|
|
|
nil ->
|
|
create_subscriber(email, opts)
|
|
end
|
|
end
|
|
|
|
defp create_subscriber(email, opts) do
|
|
{raw_token, hashed_token} = generate_token()
|
|
|
|
attrs = %{
|
|
email: email,
|
|
status: "pending",
|
|
confirmation_token: hashed_token,
|
|
consent_text: Keyword.get(opts, :consent_text),
|
|
source: Keyword.get(opts, :source, "website"),
|
|
ip_hash: Keyword.get(opts, :ip_hash)
|
|
}
|
|
|
|
case %Subscriber{} |> Subscriber.changeset(attrs) |> Repo.insert() do
|
|
{:ok, sub} ->
|
|
enqueue_confirmation(%{sub | confirmation_token: raw_token})
|
|
{:ok, sub}
|
|
|
|
{:error, changeset} ->
|
|
{:error, changeset}
|
|
end
|
|
end
|
|
|
|
defp resubscribe(sub, opts) do
|
|
{raw_token, hashed_token} = generate_token()
|
|
|
|
attrs = %{
|
|
status: "pending",
|
|
confirmation_token: hashed_token,
|
|
unsubscribed_at: nil,
|
|
consent_text: Keyword.get(opts, :consent_text) || sub.consent_text,
|
|
ip_hash: Keyword.get(opts, :ip_hash) || sub.ip_hash
|
|
}
|
|
|
|
case sub |> Subscriber.changeset(attrs) |> Repo.update() do
|
|
{:ok, updated} ->
|
|
enqueue_confirmation(%{updated | confirmation_token: raw_token})
|
|
{:ok, updated}
|
|
|
|
{:error, changeset} ->
|
|
{:error, changeset}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Confirms a subscriber via their raw confirmation token.
|
|
"""
|
|
def confirm(raw_token) do
|
|
hashed = hash_token(raw_token)
|
|
|
|
case Repo.get_by(Subscriber, confirmation_token: hashed) do
|
|
%Subscriber{status: "pending"} = sub ->
|
|
sub
|
|
|> Subscriber.changeset(%{
|
|
status: "confirmed",
|
|
confirmed_at: DateTime.utc_now() |> DateTime.truncate(:second),
|
|
confirmation_token: nil
|
|
})
|
|
|> Repo.update()
|
|
|
|
%Subscriber{status: "confirmed"} = sub ->
|
|
{:ok, sub}
|
|
|
|
_ ->
|
|
{:error, :invalid_token}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Unsubscribes an email. Updates the subscriber record and adds to the
|
|
global email suppression list.
|
|
"""
|
|
def unsubscribe(email) do
|
|
email = email |> String.trim() |> String.downcase()
|
|
|
|
case Repo.get_by(Subscriber, email: email) do
|
|
%Subscriber{status: "unsubscribed"} = sub ->
|
|
{:ok, sub}
|
|
|
|
%Subscriber{} = sub ->
|
|
sub
|
|
|> Subscriber.changeset(%{
|
|
status: "unsubscribed",
|
|
unsubscribed_at: DateTime.utc_now() |> DateTime.truncate(:second),
|
|
confirmation_token: nil
|
|
})
|
|
|> Repo.update()
|
|
|
|
nil ->
|
|
{:error, :not_found}
|
|
end
|
|
end
|
|
|
|
# ── Subscriber queries ───────────────────────────────────────────
|
|
|
|
@doc "Lists subscribers, optionally filtered by status or email search."
|
|
def list_subscribers(opts \\ []) do
|
|
query =
|
|
from(s in Subscriber, order_by: [desc: s.inserted_at])
|
|
|
|
query =
|
|
case Keyword.get(opts, :status) do
|
|
nil -> query
|
|
"all" -> query
|
|
status -> from(s in query, where: s.status == ^status)
|
|
end
|
|
|
|
query =
|
|
case Keyword.get(opts, :search) do
|
|
nil -> query
|
|
"" -> query
|
|
term -> from(s in query, where: like(s.email, ^"%#{term}%"))
|
|
end
|
|
|
|
Repo.all(query)
|
|
end
|
|
|
|
@doc """
|
|
Like `list_subscribers/1` but returns a `%Pagination{}` struct.
|
|
"""
|
|
def list_subscribers_paginated(opts \\ []) do
|
|
query = from(s in Subscriber, order_by: [desc: s.inserted_at])
|
|
|
|
query =
|
|
case Keyword.get(opts, :status) do
|
|
nil -> query
|
|
"all" -> query
|
|
status -> from(s in query, where: s.status == ^status)
|
|
end
|
|
|
|
query =
|
|
case Keyword.get(opts, :search) do
|
|
nil -> query
|
|
"" -> query
|
|
term -> from(s in query, where: like(s.email, ^"%#{term}%"))
|
|
end
|
|
|
|
Berrypod.Pagination.paginate(query, page: opts[:page], per_page: opts[:per_page] || 25)
|
|
end
|
|
|
|
@doc "Returns subscriber counts grouped by status."
|
|
def count_subscribers_by_status do
|
|
from(s in Subscriber,
|
|
group_by: s.status,
|
|
select: {s.status, count(s.id)}
|
|
)
|
|
|> Repo.all()
|
|
|> Map.new()
|
|
end
|
|
|
|
@doc "Returns the count of confirmed subscribers."
|
|
def confirmed_subscriber_count do
|
|
from(s in Subscriber, where: s.status == "confirmed", select: count())
|
|
|> Repo.one()
|
|
end
|
|
|
|
def get_subscriber!(id), do: Repo.get!(Subscriber, id)
|
|
|
|
@doc "Hard-deletes a subscriber (GDPR right to erasure)."
|
|
def delete_subscriber(%Subscriber{} = sub) do
|
|
Repo.delete(sub)
|
|
end
|
|
|
|
@doc "Returns all data held about a subscriber as a plain map (GDPR right to access)."
|
|
def export_subscriber_data(%Subscriber{} = sub) do
|
|
%{
|
|
email: sub.email,
|
|
status: sub.status,
|
|
subscribed_at: sub.inserted_at,
|
|
confirmed_at: sub.confirmed_at,
|
|
unsubscribed_at: sub.unsubscribed_at,
|
|
source: sub.source,
|
|
consent_text: sub.consent_text
|
|
}
|
|
end
|
|
|
|
@doc "Exports all subscribers as a CSV string."
|
|
def export_all_subscribers_csv do
|
|
subscribers = list_subscribers()
|
|
|
|
header = "email,status,confirmed_at,subscribed_at\n"
|
|
|
|
rows =
|
|
Enum.map_join(subscribers, "\n", fn s ->
|
|
[
|
|
s.email,
|
|
s.status,
|
|
format_csv_datetime(s.confirmed_at),
|
|
format_csv_datetime(s.inserted_at)
|
|
]
|
|
|> Enum.join(",")
|
|
end)
|
|
|
|
header <> rows
|
|
end
|
|
|
|
defp format_csv_datetime(nil), do: ""
|
|
defp format_csv_datetime(dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S")
|
|
|
|
# ── Campaign CRUD ────────────────────────────────────────────────
|
|
|
|
def list_campaigns do
|
|
from(c in Campaign, order_by: [desc: c.inserted_at])
|
|
|> Repo.all()
|
|
end
|
|
|
|
def list_campaigns_paginated(opts \\ []) do
|
|
from(c in Campaign, order_by: [desc: c.inserted_at])
|
|
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25)
|
|
end
|
|
|
|
def get_campaign!(id), do: Repo.get!(Campaign, id)
|
|
|
|
def create_campaign(attrs) do
|
|
%Campaign{}
|
|
|> Campaign.changeset(attrs)
|
|
|> Repo.insert()
|
|
end
|
|
|
|
def update_campaign(%Campaign{} = campaign, attrs) do
|
|
campaign
|
|
|> Campaign.changeset(attrs)
|
|
|> Repo.update()
|
|
end
|
|
|
|
def delete_campaign(%Campaign{status: "draft"} = campaign) do
|
|
Repo.delete(campaign)
|
|
end
|
|
|
|
def delete_campaign(_campaign), do: {:error, :not_draft}
|
|
|
|
@doc "Marks a campaign as sending and enqueues the send worker."
|
|
def send_campaign_now(%Campaign{status: status} = campaign)
|
|
when status in ["draft", "scheduled"] do
|
|
case update_campaign(campaign, %{status: "sending"}) do
|
|
{:ok, updated} ->
|
|
Berrypod.Newsletter.CampaignSendWorker.enqueue(updated.id)
|
|
{:ok, updated}
|
|
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
def send_campaign_now(_), do: {:error, :invalid_status}
|
|
|
|
@doc "Schedules a campaign for future sending."
|
|
def schedule_campaign(%Campaign{status: "draft"} = campaign, %DateTime{} = at) do
|
|
update_campaign(campaign, %{status: "scheduled", scheduled_at: at})
|
|
end
|
|
|
|
def schedule_campaign(_, _), do: {:error, :not_draft}
|
|
|
|
@doc "Cancels a scheduled campaign."
|
|
def cancel_campaign(%Campaign{status: "scheduled"} = campaign) do
|
|
update_campaign(campaign, %{status: "cancelled", scheduled_at: nil})
|
|
end
|
|
|
|
def cancel_campaign(_), do: {:error, :not_scheduled}
|
|
|
|
@doc "Returns a preview of the campaign body with a sample unsubscribe URL."
|
|
def preview_campaign(%Campaign{body: body}) do
|
|
sample_url = BerrypodWeb.Endpoint.url() <> "/unsubscribe/sample-token"
|
|
String.replace(body, "{{unsubscribe_url}}", sample_url)
|
|
end
|
|
|
|
# ── Settings ─────────────────────────────────────────────────────
|
|
|
|
def newsletter_enabled? do
|
|
Settings.get_setting("newsletter_enabled", false) == true
|
|
end
|
|
|
|
def set_newsletter_enabled(enabled?) when is_boolean(enabled?) do
|
|
Settings.put_setting("newsletter_enabled", enabled?, "boolean")
|
|
end
|
|
|
|
# ── Token helpers ────────────────────────────────────────────────
|
|
|
|
defp generate_token do
|
|
raw = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)
|
|
{raw, hash_token(raw)}
|
|
end
|
|
|
|
defp hash_token(raw) do
|
|
:crypto.hash(:sha256, raw) |> Base.encode16(case: :lower)
|
|
end
|
|
|
|
# ── Confirmation email enqueue ───────────────────────────────────
|
|
|
|
defp enqueue_confirmation(subscriber) do
|
|
%{subscriber_id: subscriber.id, raw_token: subscriber.confirmation_token}
|
|
|> Berrypod.Newsletter.ConfirmationEmailWorker.new()
|
|
|> Oban.insert()
|
|
end
|
|
end
|