berrypod/lib/berrypod/newsletter.ex
jamey 3480b326a9
All checks were successful
deploy / deploy (push) Successful in 1m38s
add pagination across all admin and shop views
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>
2026-03-01 09:42:34 +00:00

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