From ad2e6d1e6d4227824811f589054202c8bcee891c Mon Sep 17 00:00:00 2001 From: jamey Date: Sat, 28 Feb 2026 23:25:28 +0000 Subject: [PATCH] add newsletter and email campaigns Subscribers with double opt-in confirmation, campaign composer with draft/scheduled/sent lifecycle, admin dashboard with overview stats, CSV export, and shop signup form wired into page builder blocks. Co-Authored-By: Claude Opus 4.6 --- PROGRESS.md | 20 +- assets/css/admin/utilities.css | 17 + config/config.exs | 6 +- docs/plans/admin-ux-polish.md | 2 +- lib/berrypod/newsletter.ex | 314 +++++++++++ lib/berrypod/newsletter/campaign.ex | 27 + .../newsletter/campaign_send_worker.ex | 75 +++ lib/berrypod/newsletter/cleanup_worker.ex | 39 ++ .../newsletter/confirmation_email_worker.ex | 36 ++ lib/berrypod/newsletter/notifier.ex | 98 ++++ .../newsletter/scheduled_campaign_worker.ex | 36 ++ lib/berrypod/newsletter/subscriber.ex | 39 ++ lib/berrypod/pages/block_types.ex | 4 +- .../components/layouts/admin.html.heex | 8 + .../components/shop_components/content.ex | 80 ++- .../components/shop_components/layout.ex | 14 +- .../controllers/newsletter_controller.ex | 108 ++++ .../newsletter_export_controller.ex | 16 + .../controllers/unsubscribe_controller.ex | 5 +- lib/berrypod_web/live/admin/newsletter.ex | 501 ++++++++++++++++++ .../live/admin/newsletter/campaign_form.ex | 236 +++++++++ lib/berrypod_web/newsletter_hook.ex | 60 +++ lib/berrypod_web/page_renderer.ex | 12 +- lib/berrypod_web/router.ex | 11 +- .../20260228204908_create_newsletter.exs | 39 ++ .../newsletter/campaign_send_worker_test.exs | 50 ++ .../newsletter/cleanup_worker_test.exs | 48 ++ .../confirmation_email_worker_test.exs | 45 ++ test/berrypod/newsletter_test.exs | 316 +++++++++++ .../newsletter_controller_test.exs | 96 ++++ .../live/admin/newsletter_test.exs | 131 +++++ test/support/fixtures/newsletter_fixtures.ex | 40 ++ 32 files changed, 2497 insertions(+), 32 deletions(-) create mode 100644 lib/berrypod/newsletter.ex create mode 100644 lib/berrypod/newsletter/campaign.ex create mode 100644 lib/berrypod/newsletter/campaign_send_worker.ex create mode 100644 lib/berrypod/newsletter/cleanup_worker.ex create mode 100644 lib/berrypod/newsletter/confirmation_email_worker.ex create mode 100644 lib/berrypod/newsletter/notifier.ex create mode 100644 lib/berrypod/newsletter/scheduled_campaign_worker.ex create mode 100644 lib/berrypod/newsletter/subscriber.ex create mode 100644 lib/berrypod_web/controllers/newsletter_controller.ex create mode 100644 lib/berrypod_web/controllers/newsletter_export_controller.ex create mode 100644 lib/berrypod_web/live/admin/newsletter.ex create mode 100644 lib/berrypod_web/live/admin/newsletter/campaign_form.ex create mode 100644 lib/berrypod_web/newsletter_hook.ex create mode 100644 priv/repo/migrations/20260228204908_create_newsletter.exs create mode 100644 test/berrypod/newsletter/campaign_send_worker_test.exs create mode 100644 test/berrypod/newsletter/cleanup_worker_test.exs create mode 100644 test/berrypod/newsletter/confirmation_email_worker_test.exs create mode 100644 test/berrypod/newsletter_test.exs create mode 100644 test/berrypod_web/controllers/newsletter_controller_test.exs create mode 100644 test/berrypod_web/live/admin/newsletter_test.exs create mode 100644 test/support/fixtures/newsletter_fixtures.ex diff --git a/PROGRESS.md b/PROGRESS.md index cd9bd5b..498cbba 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -128,16 +128,16 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m | 91 | Order timeline component on `/admin/orders/:id` — chronological feed replacing scattered field cards | 89 | 1.5h | planned | | 92 | Global `/admin/activity` LiveView — all activity + "needs attention" tab, resolve action, count badge on admin nav | 89 | 2h | planned | | | **Admin & page editor UX polish** ([plan](docs/plans/admin-ux-polish.md)) | | | | -| 103 | Unsaved changes warning — `beforeunload` + LiveView nav guard on page editor | — | 30m | planned | -| 104 | Block descriptions in picker — add subtitle text to each block type | — | 45m | planned | -| 105 | Sidebar nav grouping — section headers or nest Email/Redirects under Settings | — | 45m | planned | -| 106 | Nav editor input labels — visible labels above each input pair | — | 30m | planned | -| 107 | Custom page settings inline — collapsible panel in editor instead of separate page | — | 1h | planned | -| 108 | Preview with real data — load actual products/categories instead of PreviewData | — | 45m | planned | -| 109 | Block content preview in list — one-line summary below block name | — | 45m | planned | -| 110 | "Providers" label clarity — rename to "Print providers" in sidebar | — | 5m | planned | -| 111 | Newsletter block backend — wire up email collection or mark as decorative | — | 1-3h | planned | -| 112 | Block preview thumbnails in picker — small illustrations per block type | — | 2h | planned | +| ~~103~~ | ~~Unsaved changes warning — `beforeunload` + LiveView nav guard on page editor~~ | — | 30m | done | +| ~~104~~ | ~~Block descriptions in picker — add subtitle text to each block type~~ | — | 45m | done | +| ~~105~~ | ~~Sidebar nav grouping — section headers (Shop/Content/Settings)~~ | — | 45m | done | +| ~~106~~ | ~~Nav editor input labels — visible labels above each input pair~~ | — | 30m | done | +| ~~107~~ | ~~Custom page settings inline — collapsible panel in editor~~ | — | 1h | done | +| ~~108~~ | ~~Preview with real data — load actual products/categories~~ | — | 45m | done | +| ~~109~~ | ~~Block content preview in list — one-line summary below block name~~ | — | 45m | done | +| ~~110~~ | ~~"Providers" label clarity — renamed to "Print providers"~~ | — | 5m | done | +| ~~111~~ | ~~Newsletter block backend — marked decorative with configurable settings~~ | — | 30m | done | +| ~~112~~ | ~~Block preview thumbnails in picker — SVG wireframes per block type~~ | — | 2h | done | | | **Other features** | | | | | ~~72~~ | ~~Order status lookup — wire up existing stub on contact page (UI already exists, backend unbuilt)~~ | — | 1.5h | done | | | **Abandoned cart recovery** ([plan](docs/plans/abandoned-cart.md)) | | | | diff --git a/assets/css/admin/utilities.css b/assets/css/admin/utilities.css index 7490825..2b76560 100644 --- a/assets/css/admin/utilities.css +++ b/assets/css/admin/utilities.css @@ -132,6 +132,7 @@ .mt-8 { margin-top: 2rem; } .mt-10 { margin-top: 2.5rem; } .-mt-1 { margin-top: -0.25rem; } +.-mb-px { margin-bottom: -1px; } .mb-0\.5 { margin-bottom: 0.125rem; } .mb-2 { margin-bottom: 0.5rem; } .mb-3 { margin-bottom: 0.75rem; } @@ -158,6 +159,7 @@ ======================================== */ .w-0 { width: 0; } +.w-1\.5 { width: 0.375rem; } .w-3 { width: 0.75rem; } .w-4 { width: 1rem; } .w-5 { width: 1.25rem; } @@ -202,6 +204,7 @@ .min-w-48 { min-width: 12rem; } .max-h-full { max-height: 100%; } +.max-h-64 { max-height: 16rem; } .max-w-80 { max-width: 20rem; } .max-w-sm { max-width: 24rem; } .max-w-md { max-width: 28rem; } @@ -241,6 +244,7 @@ .text-wrap { text-wrap: wrap; } .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.whitespace-pre-wrap { white-space: pre-wrap; } .break-all { word-break: break-all; } .uppercase { text-transform: uppercase; } .capitalize { text-transform: capitalize; } @@ -308,17 +312,20 @@ .border-green-200 { border-color: #bbf7d0; } .bg-red-50 { background-color: #fef2f2; } +.bg-red-500 { background-color: #ef4444; } .text-red-600 { color: #dc2626; } .text-red-700 { color: #b91c1c; } .bg-amber-50 { background-color: #fffbeb; } .bg-amber-100 { background-color: #fef3c7; } +.bg-amber-500 { background-color: #f59e0b; } .text-amber-600 { color: #d97706; } .text-amber-700 { color: #b45309; } .text-amber-800 { color: #92400e; } .text-amber-900 { color: #78350f; } .bg-blue-50 { background-color: #eff6ff; } +.bg-blue-500 { background-color: #3b82f6; } .text-blue-700 { color: #1d4ed8; } .bg-purple-50 { background-color: #faf5ff; } @@ -342,6 +349,7 @@ .border-t-0 { border-top-width: 0; } .border-dashed { border-style: dashed; } +.border-transparent { border-color: transparent; } .border-base-200 { border-color: var(--t-surface-sunken); } .border-base-300 { border-color: var(--t-border-default); } .border-base-content\/20 { border-color: color-mix(in oklch, var(--t-text-primary) 20%, transparent); } @@ -375,6 +383,10 @@ Ring (outline via box-shadow) ======================================== */ +.ring-0 { + --tw-ring-shadow: 0 0 #0000; + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); +} .ring-1 { --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 1px var(--tw-ring-color); box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); @@ -391,6 +403,10 @@ Shadow ======================================== */ +.shadow { + --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); +} .shadow-xs { --tw-shadow: 0 1px rgb(0 0 0 / 0.05); box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); @@ -427,6 +443,7 @@ ======================================== */ .cursor-pointer { cursor: pointer; } +.pointer-events-none { pointer-events: none; } /* ======================================== Gradient diff --git a/config/config.exs b/config/config.exs index c955a8e..faca4fe 100644 --- a/config/config.exs +++ b/config/config.exs @@ -96,10 +96,12 @@ config :berrypod, Oban, {"0 */6 * * *", Berrypod.Sync.ScheduledSyncWorker}, {"0 3 * * *", Berrypod.Analytics.RetentionWorker}, {"0 4 * * *", Berrypod.Orders.AbandonedCartPruneWorker}, - {"0 5 * * 1", Berrypod.Workers.RedirectPrunerWorker} + {"0 5 * * 1", Berrypod.Workers.RedirectPrunerWorker}, + {"0 2 * * *", Berrypod.Newsletter.CleanupWorker}, + {"*/5 * * * *", Berrypod.Newsletter.ScheduledCampaignWorker} ]} ], - queues: [images: 2, sync: 1, checkout: 1] + queues: [images: 2, sync: 1, checkout: 1, newsletter: 1] # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. diff --git a/docs/plans/admin-ux-polish.md b/docs/plans/admin-ux-polish.md index 3a2960f..7f35d27 100644 --- a/docs/plans/admin-ux-polish.md +++ b/docs/plans/admin-ux-polish.md @@ -1,6 +1,6 @@ # Admin & page editor UX polish -Status: Planned +Status: Complete ## Context diff --git a/lib/berrypod/newsletter.ex b/lib/berrypod/newsletter.ex new file mode 100644 index 0000000..7228d5a --- /dev/null +++ b/lib/berrypod/newsletter.ex @@ -0,0 +1,314 @@ +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 "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 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 diff --git a/lib/berrypod/newsletter/campaign.ex b/lib/berrypod/newsletter/campaign.ex new file mode 100644 index 0000000..d1e8269 --- /dev/null +++ b/lib/berrypod/newsletter/campaign.ex @@ -0,0 +1,27 @@ +defmodule Berrypod.Newsletter.Campaign do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "newsletter_campaigns" do + field :subject, :string + field :body, :string + field :status, :string, default: "draft" + field :scheduled_at, :utc_datetime + field :sent_at, :utc_datetime + field :sent_count, :integer, default: 0 + field :failed_count, :integer, default: 0 + + timestamps(type: :utc_datetime) + end + + def changeset(campaign, attrs) do + campaign + |> cast(attrs, [:subject, :body, :status, :scheduled_at, :sent_at, :sent_count, :failed_count]) + |> validate_required([:subject, :body]) + |> validate_length(:subject, max: 200) + |> validate_inclusion(:status, ~w(draft scheduled sending sent cancelled)) + end +end diff --git a/lib/berrypod/newsletter/campaign_send_worker.ex b/lib/berrypod/newsletter/campaign_send_worker.ex new file mode 100644 index 0000000..25da2cc --- /dev/null +++ b/lib/berrypod/newsletter/campaign_send_worker.ex @@ -0,0 +1,75 @@ +defmodule Berrypod.Newsletter.CampaignSendWorker do + @moduledoc """ + Sends a campaign to all confirmed, non-suppressed subscribers. + + Processes in batches of 50 with a brief pause between batches to + respect mail provider rate limits. + """ + + use Oban.Worker, queue: :newsletter, max_attempts: 1 + + alias Berrypod.Newsletter + alias Berrypod.Newsletter.Notifier + + require Logger + + @batch_size 50 + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"campaign_id" => id}}) do + campaign = Newsletter.get_campaign!(id) + + unless campaign.status in ["sending", "scheduled"] do + Logger.info("Campaign #{id} status is #{campaign.status}, skipping") + return_ok() + end + + # Ensure status is "sending" + {:ok, campaign} = + if campaign.status == "scheduled" do + Newsletter.update_campaign(campaign, %{status: "sending"}) + else + {:ok, campaign} + end + + subscribers = Newsletter.list_subscribers(status: "confirmed") + {sent, failed} = send_in_batches(campaign, subscribers) + + Newsletter.update_campaign(campaign, %{ + status: "sent", + sent_at: DateTime.utc_now() |> DateTime.truncate(:second), + sent_count: sent, + failed_count: failed + }) + + Logger.info("Campaign #{id} sent: #{sent} delivered, #{failed} failed") + :ok + end + + def enqueue(campaign_id) do + %{campaign_id: campaign_id} + |> new() + |> Oban.insert() + end + + defp send_in_batches(campaign, subscribers) do + subscribers + |> Enum.chunk_every(@batch_size) + |> Enum.reduce({0, 0}, fn batch, {sent, failed} -> + {batch_sent, batch_failed} = send_batch(campaign, batch) + Process.sleep(100) + {sent + batch_sent, failed + batch_failed} + end) + end + + defp send_batch(campaign, subscribers) do + Enum.reduce(subscribers, {0, 0}, fn sub, {sent, failed} -> + case Notifier.deliver_campaign(campaign, sub) do + {:ok, _} -> {sent + 1, failed} + {:error, _} -> {sent, failed + 1} + end + end) + end + + defp return_ok, do: :ok +end diff --git a/lib/berrypod/newsletter/cleanup_worker.ex b/lib/berrypod/newsletter/cleanup_worker.ex new file mode 100644 index 0000000..d134c3b --- /dev/null +++ b/lib/berrypod/newsletter/cleanup_worker.ex @@ -0,0 +1,39 @@ +defmodule Berrypod.Newsletter.CleanupWorker do + @moduledoc """ + Prunes unconfirmed newsletter subscribers older than 48 hours. + + Runs daily via cron. Pending signups that never confirmed are deleted + to keep the subscriber list clean and respect data minimisation. + """ + + use Oban.Worker, queue: :newsletter, max_attempts: 1 + + import Ecto.Query + + alias Berrypod.Newsletter.Subscriber + alias Berrypod.Repo + + require Logger + + @hours_to_expire 48 + + @impl Oban.Worker + def perform(%Oban.Job{}) do + cutoff = + DateTime.utc_now() + |> DateTime.add(-@hours_to_expire * 3600) + |> DateTime.truncate(:second) + + {count, _} = + from(s in Subscriber, + where: s.status == "pending" and s.inserted_at < ^cutoff + ) + |> Repo.delete_all() + + if count > 0 do + Logger.info("Newsletter cleanup: pruned #{count} unconfirmed subscribers") + end + + :ok + end +end diff --git a/lib/berrypod/newsletter/confirmation_email_worker.ex b/lib/berrypod/newsletter/confirmation_email_worker.ex new file mode 100644 index 0000000..d35dc85 --- /dev/null +++ b/lib/berrypod/newsletter/confirmation_email_worker.ex @@ -0,0 +1,36 @@ +defmodule Berrypod.Newsletter.ConfirmationEmailWorker do + @moduledoc """ + Sends the double opt-in confirmation email to a new subscriber. + """ + + use Oban.Worker, queue: :newsletter, max_attempts: 3 + + alias Berrypod.Newsletter + alias Berrypod.Newsletter.Notifier + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"subscriber_id" => id, "raw_token" => raw_token}}) do + case Berrypod.Repo.get(Newsletter.Subscriber, id) do + %{status: "pending"} = sub -> + case Notifier.deliver_confirmation(sub, raw_token) do + {:ok, _} -> + Logger.info("Newsletter confirmation sent to #{sub.email}") + :ok + + {:error, reason} -> + Logger.error("Newsletter confirmation failed for #{sub.email}: #{inspect(reason)}") + {:error, reason} + end + + %{status: _other} -> + Logger.info("Newsletter confirmation skipped: subscriber #{id} no longer pending") + :ok + + nil -> + Logger.warning("Newsletter confirmation: subscriber #{id} not found") + {:cancel, :not_found} + end + end +end diff --git a/lib/berrypod/newsletter/notifier.ex b/lib/berrypod/newsletter/notifier.ex new file mode 100644 index 0000000..675751f --- /dev/null +++ b/lib/berrypod/newsletter/notifier.ex @@ -0,0 +1,98 @@ +defmodule Berrypod.Newsletter.Notifier do + @moduledoc """ + Plain text email templates for newsletter confirmation and campaigns. + """ + + import Swoosh.Email + + alias Berrypod.Mailer + + require Logger + + @doc "Sends the double opt-in confirmation email." + def deliver_confirmation(subscriber, raw_token) do + shop_name = Berrypod.Settings.get_setting("shop_name", "Berrypod") + confirm_url = BerrypodWeb.Endpoint.url() <> "/newsletter/confirm/" <> raw_token + + body = """ + ============================== + + Thanks for signing up to the #{shop_name} newsletter! + + Please confirm your email to start receiving updates: + + #{confirm_url} + + This link expires in 48 hours. If you didn't subscribe, + just ignore this email and you won't hear from us. + + ============================== + """ + + deliver(subscriber.email, "Confirm your subscription", body) + end + + @doc "Sends a campaign email to a single subscriber." + def deliver_campaign(campaign, subscriber) do + shop_name = Berrypod.Settings.get_setting("shop_name", "Berrypod") + unsubscribe_url = build_unsubscribe_url(subscriber.email) + + body_with_url = + String.replace(campaign.body, "{{unsubscribe_url}}", unsubscribe_url) + + full_body = """ + #{body_with_url} + + --- + You're receiving this because you subscribed to the #{shop_name} newsletter. + Unsubscribe: #{unsubscribe_url} + """ + + email = + new() + |> to(subscriber.email) + |> from({shop_name, from_address()}) + |> subject(campaign.subject) + |> text_body(full_body) + |> header("List-Unsubscribe", "<#{unsubscribe_url}>") + |> header("List-Unsubscribe-Post", "List-Unsubscribe=One-Click") + + case Mailer.deliver(email) do + {:ok, _metadata} = result -> + result + + {:error, reason} = error -> + Logger.warning("Newsletter send failed for #{subscriber.email}: #{inspect(reason)}") + error + end + end + + defp deliver(recipient, subject, body) do + shop_name = Berrypod.Settings.get_setting("shop_name", "Berrypod") + + email = + new() + |> to(recipient) + |> from({shop_name, from_address()}) + |> subject(subject) + |> text_body(body) + + case Mailer.deliver(email) do + {:ok, _metadata} = result -> + result + + {:error, reason} = error -> + Logger.warning("Failed to send newsletter email to #{recipient}: #{inspect(reason)}") + error + end + end + + defp from_address do + Berrypod.Settings.get_setting("email_from_address", "noreply@example.com") + end + + defp build_unsubscribe_url(email) do + token = Phoenix.Token.sign(BerrypodWeb.Endpoint, "email-unsub", email) + BerrypodWeb.Endpoint.url() <> "/unsubscribe/" <> token + end +end diff --git a/lib/berrypod/newsletter/scheduled_campaign_worker.ex b/lib/berrypod/newsletter/scheduled_campaign_worker.ex new file mode 100644 index 0000000..1f31893 --- /dev/null +++ b/lib/berrypod/newsletter/scheduled_campaign_worker.ex @@ -0,0 +1,36 @@ +defmodule Berrypod.Newsletter.ScheduledCampaignWorker do + @moduledoc """ + Picks up scheduled campaigns that are due and triggers sending. + + Runs every 5 minutes via cron. Simpler than using Oban's schedule_in + because it lets admins cancel scheduled campaigns by changing status. + """ + + use Oban.Worker, queue: :newsletter, max_attempts: 1 + + import Ecto.Query + + alias Berrypod.Newsletter + alias Berrypod.Newsletter.Campaign + alias Berrypod.Repo + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{}) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + campaigns = + from(c in Campaign, + where: c.status == "scheduled" and c.scheduled_at <= ^now + ) + |> Repo.all() + + for campaign <- campaigns do + Logger.info("Triggering scheduled campaign #{campaign.id}: #{campaign.subject}") + Newsletter.send_campaign_now(campaign) + end + + :ok + end +end diff --git a/lib/berrypod/newsletter/subscriber.ex b/lib/berrypod/newsletter/subscriber.ex new file mode 100644 index 0000000..3a54949 --- /dev/null +++ b/lib/berrypod/newsletter/subscriber.ex @@ -0,0 +1,39 @@ +defmodule Berrypod.Newsletter.Subscriber do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "newsletter_subscribers" do + field :email, :string + field :status, :string, default: "pending" + field :confirmation_token, :string + field :confirmed_at, :utc_datetime + field :unsubscribed_at, :utc_datetime + field :consent_text, :string + field :source, :string, default: "website" + field :ip_hash, :string + + timestamps(type: :utc_datetime) + end + + def changeset(subscriber, attrs) do + subscriber + |> cast(attrs, [ + :email, + :status, + :confirmation_token, + :confirmed_at, + :unsubscribed_at, + :consent_text, + :source, + :ip_hash + ]) + |> validate_required([:email]) + |> validate_format(:email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/) + |> validate_inclusion(:status, ~w(pending confirmed unsubscribed)) + |> unique_constraint(:email) + |> update_change(:email, &String.downcase(String.trim(&1))) + end +end diff --git a/lib/berrypod/pages/block_types.ex b/lib/berrypod/pages/block_types.ex index b266b66..925cb05 100644 --- a/lib/berrypod/pages/block_types.ex +++ b/lib/berrypod/pages/block_types.ex @@ -110,11 +110,9 @@ defmodule Berrypod.Pages.BlockTypes do }, "newsletter_card" => %{ name: "Newsletter signup", - description: "Email signup form — currently decorative, does not collect emails", + description: "Email signup form with double opt-in — enable in admin settings", icon: "hero-envelope", allowed_on: :all, - hint: - "This block is decorative — form submissions aren't collected yet. Use it as a placeholder or remove it.", settings_schema: [ %SettingsField{key: "title", label: "Title", type: :text, default: "Newsletter"}, %SettingsField{ diff --git a/lib/berrypod_web/components/layouts/admin.html.heex b/lib/berrypod_web/components/layouts/admin.html.heex index d9b967e..1bab07d 100644 --- a/lib/berrypod_web/components/layouts/admin.html.heex +++ b/lib/berrypod_web/components/layouts/admin.html.heex @@ -126,6 +126,14 @@ <.icon name="hero-photo" class="size-5" /> Media +
  • + <.link + navigate={~p"/admin/newsletter"} + class={admin_nav_active?(@current_path, "/admin/newsletter")} + > + <.icon name="hero-megaphone" class="size-5" /> Newsletter + +
  • <.link href={~p"/admin/theme"} diff --git a/lib/berrypod_web/components/shop_components/content.ex b/lib/berrypod_web/components/shop_components/content.ex index 68a22c8..5d3bbad 100644 --- a/lib/berrypod_web/components/shop_components/content.ex +++ b/lib/berrypod_web/components/shop_components/content.ex @@ -286,16 +286,18 @@ defmodule BerrypodWeb.ShopComponents.Content do ## Attributes - * `title` - Optional. Card heading. Defaults to "Stay in touch". + * `title` - Optional. Card heading. Defaults to "Newsletter". * `description` - Optional. Card description. * `button_text` - Optional. Button text. Defaults to "Subscribe". * `variant` - Optional. Either `:card` (default, with border/background) or `:inline` (no card styling, for embedding in footer). + * `newsletter_state` - Optional. `:idle | :submitted | :error | :disabled`. Defaults to `:idle`. + * `newsletter_enabled` - Optional. Whether signups are active. Defaults to `true`. ## Examples <.newsletter_card /> <.newsletter_card title="Studio news" description="Get updates on new products." /> - <.newsletter_card variant={:inline} /> + <.newsletter_card variant={:inline} newsletter_state={@newsletter_state} /> """ attr :title, :string, default: "Newsletter" @@ -304,6 +306,30 @@ defmodule BerrypodWeb.ShopComponents.Content do attr :button_text, :string, default: "Subscribe" attr :variant, :atom, default: :card + attr :newsletter_state, :atom, default: :idle + attr :newsletter_enabled, :boolean, default: true + + def newsletter_card(%{newsletter_state: :submitted, variant: :inline} = assigns) do + ~H""" +
    + +

    + Check your inbox to confirm your subscription. +

    +
    + """ + end + + def newsletter_card(%{newsletter_state: :submitted} = assigns) do + ~H""" + <.shop_card class="card-section"> +

    {@title}

    +

    + Check your inbox to confirm your subscription. +

    + + """ + end def newsletter_card(%{variant: :inline} = assigns) do ~H""" @@ -314,10 +340,27 @@ defmodule BerrypodWeb.ShopComponents.Content do

    {@description}

    -
    - <.shop_input type="email" placeholder="your@email.com" class="email-input" /> - <.shop_button type="submit">{@button_text} -
    + <%= if @newsletter_enabled do %> +
    + + <.shop_input + type="email" + name="email" + placeholder="your@email.com" + class="email-input" + required + /> + <.shop_button type="submit">{@button_text} +
    + + <% end %> """ end @@ -329,10 +372,27 @@ defmodule BerrypodWeb.ShopComponents.Content do

    {@description}

    -
    - <.shop_input type="email" placeholder="your@email.com" class="email-input" /> - <.shop_button type="submit">{@button_text} -
    + <%= if @newsletter_enabled do %> +
    + + <.shop_input + type="email" + name="email" + placeholder="your@email.com" + class="email-input" + required + /> + <.shop_button type="submit">{@button_text} +
    + + <% end %> """ end diff --git a/lib/berrypod_web/components/shop_components/layout.ex b/lib/berrypod_web/components/shop_components/layout.ex index c168238..c5d5375 100644 --- a/lib/berrypod_web/components/shop_components/layout.ex +++ b/lib/berrypod_web/components/shop_components/layout.ex @@ -52,7 +52,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do cart_subtotal cart_total cart_drawer_open cart_status active_page error_page is_admin search_query search_results search_open categories shipping_estimate country_code available_countries editing editor_current_path editor_sidebar_open - header_nav_items footer_nav_items)a + header_nav_items footer_nav_items newsletter_enabled newsletter_state)a @doc """ Extracts the assigns relevant to `shop_layout` from a full assigns map. @@ -98,6 +98,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do attr :available_countries, :list, default: [] attr :header_nav_items, :list, default: [] attr :footer_nav_items, :list, default: [] + attr :newsletter_enabled, :boolean, default: false + attr :newsletter_state, :atom, default: :idle slot :inner_block, required: true @@ -136,6 +138,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do mode={@mode} categories={assigns[:categories] || []} footer_nav_items={@footer_nav_items} + newsletter_enabled={@newsletter_enabled} + newsletter_state={@newsletter_state} /> <.cart_drawer @@ -522,6 +526,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do attr :mode, :atom, default: :live attr :categories, :list, default: [] attr :footer_nav_items, :list, default: [] + attr :newsletter_enabled, :boolean, default: false + attr :newsletter_state, :atom, default: :idle def shop_footer(assigns) do assigns = assign(assigns, :current_year, Date.utc_today().year) @@ -530,7 +536,11 @@ defmodule BerrypodWeb.ShopComponents.Layout do