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