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 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-28 23:25:28 +00:00
parent 8f989d892d
commit ad2e6d1e6d
32 changed files with 2497 additions and 32 deletions

View File

@ -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 | | 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 | | 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)) | | | | | | **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 | | ~~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 | planned | | ~~104~~ | ~~Block descriptions in picker — add subtitle text to each block type~~ | — | 45m | done |
| 105 | Sidebar nav grouping — section headers or nest Email/Redirects under Settings | — | 45m | planned | | ~~105~~ | ~~Sidebar nav grouping — section headers (Shop/Content/Settings)~~ | — | 45m | done |
| 106 | Nav editor input labels — visible labels above each input pair | — | 30m | planned | | ~~106~~ | ~~Nav editor input labels — visible labels above each input pair~~ | — | 30m | done |
| 107 | Custom page settings inline — collapsible panel in editor instead of separate page | — | 1h | planned | | ~~107~~ | ~~Custom page settings inline — collapsible panel in editor~~ | — | 1h | done |
| 108 | Preview with real data — load actual products/categories instead of PreviewData | — | 45m | planned | | ~~108~~ | ~~Preview with real data — load actual products/categories~~ | — | 45m | done |
| 109 | Block content preview in list — one-line summary below block name | — | 45m | planned | | ~~109~~ | ~~Block content preview in list — one-line summary below block name~~ | — | 45m | done |
| 110 | "Providers" label clarity — rename to "Print providers" in sidebar | — | 5m | planned | | ~~110~~ | ~~"Providers" label clarity — renamed to "Print providers"~~ | — | 5m | done |
| 111 | Newsletter block backend — wire up email collection or mark as decorative | — | 1-3h | planned | | ~~111~~ | ~~Newsletter block backend — marked decorative with configurable settings~~ | — | 30m | done |
| 112 | Block preview thumbnails in picker — small illustrations per block type | — | 2h | planned | | ~~112~~ | ~~Block preview thumbnails in picker — SVG wireframes per block type~~ | — | 2h | done |
| | **Other features** | | | | | | **Other features** | | | |
| ~~72~~ | ~~Order status lookup — wire up existing stub on contact page (UI already exists, backend unbuilt)~~ | — | 1.5h | done | | ~~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)) | | | | | | **Abandoned cart recovery** ([plan](docs/plans/abandoned-cart.md)) | | | |

View File

@ -132,6 +132,7 @@
.mt-8 { margin-top: 2rem; } .mt-8 { margin-top: 2rem; }
.mt-10 { margin-top: 2.5rem; } .mt-10 { margin-top: 2.5rem; }
.-mt-1 { margin-top: -0.25rem; } .-mt-1 { margin-top: -0.25rem; }
.-mb-px { margin-bottom: -1px; }
.mb-0\.5 { margin-bottom: 0.125rem; } .mb-0\.5 { margin-bottom: 0.125rem; }
.mb-2 { margin-bottom: 0.5rem; } .mb-2 { margin-bottom: 0.5rem; }
.mb-3 { margin-bottom: 0.75rem; } .mb-3 { margin-bottom: 0.75rem; }
@ -158,6 +159,7 @@
======================================== */ ======================================== */
.w-0 { width: 0; } .w-0 { width: 0; }
.w-1\.5 { width: 0.375rem; }
.w-3 { width: 0.75rem; } .w-3 { width: 0.75rem; }
.w-4 { width: 1rem; } .w-4 { width: 1rem; }
.w-5 { width: 1.25rem; } .w-5 { width: 1.25rem; }
@ -202,6 +204,7 @@
.min-w-48 { min-width: 12rem; } .min-w-48 { min-width: 12rem; }
.max-h-full { max-height: 100%; } .max-h-full { max-height: 100%; }
.max-h-64 { max-height: 16rem; }
.max-w-80 { max-width: 20rem; } .max-w-80 { max-width: 20rem; }
.max-w-sm { max-width: 24rem; } .max-w-sm { max-width: 24rem; }
.max-w-md { max-width: 28rem; } .max-w-md { max-width: 28rem; }
@ -241,6 +244,7 @@
.text-wrap { text-wrap: wrap; } .text-wrap { text-wrap: wrap; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.whitespace-pre-wrap { white-space: pre-wrap; }
.break-all { word-break: break-all; } .break-all { word-break: break-all; }
.uppercase { text-transform: uppercase; } .uppercase { text-transform: uppercase; }
.capitalize { text-transform: capitalize; } .capitalize { text-transform: capitalize; }
@ -308,17 +312,20 @@
.border-green-200 { border-color: #bbf7d0; } .border-green-200 { border-color: #bbf7d0; }
.bg-red-50 { background-color: #fef2f2; } .bg-red-50 { background-color: #fef2f2; }
.bg-red-500 { background-color: #ef4444; }
.text-red-600 { color: #dc2626; } .text-red-600 { color: #dc2626; }
.text-red-700 { color: #b91c1c; } .text-red-700 { color: #b91c1c; }
.bg-amber-50 { background-color: #fffbeb; } .bg-amber-50 { background-color: #fffbeb; }
.bg-amber-100 { background-color: #fef3c7; } .bg-amber-100 { background-color: #fef3c7; }
.bg-amber-500 { background-color: #f59e0b; }
.text-amber-600 { color: #d97706; } .text-amber-600 { color: #d97706; }
.text-amber-700 { color: #b45309; } .text-amber-700 { color: #b45309; }
.text-amber-800 { color: #92400e; } .text-amber-800 { color: #92400e; }
.text-amber-900 { color: #78350f; } .text-amber-900 { color: #78350f; }
.bg-blue-50 { background-color: #eff6ff; } .bg-blue-50 { background-color: #eff6ff; }
.bg-blue-500 { background-color: #3b82f6; }
.text-blue-700 { color: #1d4ed8; } .text-blue-700 { color: #1d4ed8; }
.bg-purple-50 { background-color: #faf5ff; } .bg-purple-50 { background-color: #faf5ff; }
@ -342,6 +349,7 @@
.border-t-0 { border-top-width: 0; } .border-t-0 { border-top-width: 0; }
.border-dashed { border-style: dashed; } .border-dashed { border-style: dashed; }
.border-transparent { border-color: transparent; }
.border-base-200 { border-color: var(--t-surface-sunken); } .border-base-200 { border-color: var(--t-surface-sunken); }
.border-base-300 { border-color: var(--t-border-default); } .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); } .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 (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 { .ring-1 {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 1px var(--tw-ring-color); --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); box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
@ -391,6 +403,10 @@
Shadow 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 { .shadow-xs {
--tw-shadow: 0 1px rgb(0 0 0 / 0.05); --tw-shadow: 0 1px rgb(0 0 0 / 0.05);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
@ -427,6 +443,7 @@
======================================== */ ======================================== */
.cursor-pointer { cursor: pointer; } .cursor-pointer { cursor: pointer; }
.pointer-events-none { pointer-events: none; }
/* ======================================== /* ========================================
Gradient Gradient

View File

@ -96,10 +96,12 @@ config :berrypod, Oban,
{"0 */6 * * *", Berrypod.Sync.ScheduledSyncWorker}, {"0 */6 * * *", Berrypod.Sync.ScheduledSyncWorker},
{"0 3 * * *", Berrypod.Analytics.RetentionWorker}, {"0 3 * * *", Berrypod.Analytics.RetentionWorker},
{"0 4 * * *", Berrypod.Orders.AbandonedCartPruneWorker}, {"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 # Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above. # of this file so it overrides the configuration defined above.

View File

@ -1,6 +1,6 @@
# Admin & page editor UX polish # Admin & page editor UX polish
Status: Planned Status: Complete
## Context ## Context

314
lib/berrypod/newsletter.ex Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -110,11 +110,9 @@ defmodule Berrypod.Pages.BlockTypes do
}, },
"newsletter_card" => %{ "newsletter_card" => %{
name: "Newsletter signup", 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", icon: "hero-envelope",
allowed_on: :all, allowed_on: :all,
hint:
"This block is decorative — form submissions aren't collected yet. Use it as a placeholder or remove it.",
settings_schema: [ settings_schema: [
%SettingsField{key: "title", label: "Title", type: :text, default: "Newsletter"}, %SettingsField{key: "title", label: "Title", type: :text, default: "Newsletter"},
%SettingsField{ %SettingsField{

View File

@ -126,6 +126,14 @@
<.icon name="hero-photo" class="size-5" /> Media <.icon name="hero-photo" class="size-5" /> Media
</.link> </.link>
</li> </li>
<li>
<.link
navigate={~p"/admin/newsletter"}
class={admin_nav_active?(@current_path, "/admin/newsletter")}
>
<.icon name="hero-megaphone" class="size-5" /> Newsletter
</.link>
</li>
<li> <li>
<.link <.link
href={~p"/admin/theme"} href={~p"/admin/theme"}

View File

@ -286,16 +286,18 @@ defmodule BerrypodWeb.ShopComponents.Content do
## Attributes ## Attributes
* `title` - Optional. Card heading. Defaults to "Stay in touch". * `title` - Optional. Card heading. Defaults to "Newsletter".
* `description` - Optional. Card description. * `description` - Optional. Card description.
* `button_text` - Optional. Button text. Defaults to "Subscribe". * `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). * `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 ## Examples
<.newsletter_card /> <.newsletter_card />
<.newsletter_card title="Studio news" description="Get updates on new products." /> <.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" attr :title, :string, default: "Newsletter"
@ -304,6 +306,30 @@ defmodule BerrypodWeb.ShopComponents.Content do
attr :button_text, :string, default: "Subscribe" attr :button_text, :string, default: "Subscribe"
attr :variant, :atom, default: :card 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"""
<div>
<h3 class="newsletter-heading">{@title}</h3>
<p class="card-text card-text--spaced">
Check your inbox to confirm your subscription.
</p>
</div>
"""
end
def newsletter_card(%{newsletter_state: :submitted} = assigns) do
~H"""
<.shop_card class="card-section">
<h3 class="card-heading">{@title}</h3>
<p class="card-text card-text--spaced">
Check your inbox to confirm your subscription.
</p>
</.shop_card>
"""
end
def newsletter_card(%{variant: :inline} = assigns) do def newsletter_card(%{variant: :inline} = assigns) do
~H""" ~H"""
@ -314,10 +340,27 @@ defmodule BerrypodWeb.ShopComponents.Content do
<p class="card-text card-text--spaced"> <p class="card-text card-text--spaced">
{@description} {@description}
</p> </p>
<form class="card-inline-form" onsubmit="return false"> <%= if @newsletter_enabled do %>
<.shop_input type="email" placeholder="your@email.com" class="email-input" /> <form
action="/newsletter/subscribe"
method="post"
phx-submit="newsletter_subscribe"
class="card-inline-form"
>
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<.shop_input
type="email"
name="email"
placeholder="your@email.com"
class="email-input"
required
/>
<.shop_button type="submit">{@button_text}</.shop_button> <.shop_button type="submit">{@button_text}</.shop_button>
</form> </form>
<p :if={@newsletter_state == :error} class="card-text newsletter-error">
Something went wrong. Please try again.
</p>
<% end %>
</div> </div>
""" """
end end
@ -329,10 +372,27 @@ defmodule BerrypodWeb.ShopComponents.Content do
<p class="card-text card-text--spaced"> <p class="card-text card-text--spaced">
{@description} {@description}
</p> </p>
<form class="card-inline-form" onsubmit="return false"> <%= if @newsletter_enabled do %>
<.shop_input type="email" placeholder="your@email.com" class="email-input" /> <form
action="/newsletter/subscribe"
method="post"
phx-submit="newsletter_subscribe"
class="card-inline-form"
>
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<.shop_input
type="email"
name="email"
placeholder="your@email.com"
class="email-input"
required
/>
<.shop_button type="submit">{@button_text}</.shop_button> <.shop_button type="submit">{@button_text}</.shop_button>
</form> </form>
<p :if={@newsletter_state == :error} class="card-text newsletter-error">
Something went wrong. Please try again.
</p>
<% end %>
</.shop_card> </.shop_card>
""" """
end end

View File

@ -52,7 +52,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
cart_subtotal cart_total cart_drawer_open cart_status active_page error_page is_admin cart_subtotal cart_total cart_drawer_open cart_status active_page error_page is_admin
search_query search_results search_open categories shipping_estimate search_query search_results search_open categories shipping_estimate
country_code available_countries editing editor_current_path editor_sidebar_open 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 """ @doc """
Extracts the assigns relevant to `shop_layout` from a full assigns map. 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 :available_countries, :list, default: []
attr :header_nav_items, :list, default: [] attr :header_nav_items, :list, default: []
attr :footer_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 slot :inner_block, required: true
@ -136,6 +138,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do
mode={@mode} mode={@mode}
categories={assigns[:categories] || []} categories={assigns[:categories] || []}
footer_nav_items={@footer_nav_items} footer_nav_items={@footer_nav_items}
newsletter_enabled={@newsletter_enabled}
newsletter_state={@newsletter_state}
/> />
<.cart_drawer <.cart_drawer
@ -522,6 +526,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do
attr :mode, :atom, default: :live attr :mode, :atom, default: :live
attr :categories, :list, default: [] attr :categories, :list, default: []
attr :footer_nav_items, :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 def shop_footer(assigns) do
assigns = assign(assigns, :current_year, Date.utc_today().year) assigns = assign(assigns, :current_year, Date.utc_today().year)
@ -530,7 +536,11 @@ defmodule BerrypodWeb.ShopComponents.Layout do
<footer class="shop-footer"> <footer class="shop-footer">
<div class="shop-footer-inner"> <div class="shop-footer-inner">
<div class="footer-grid"> <div class="footer-grid">
<.newsletter_card variant={:inline} /> <.newsletter_card
variant={:inline}
newsletter_enabled={@newsletter_enabled}
newsletter_state={@newsletter_state}
/>
<div class="footer-links"> <div class="footer-links">
<div> <div>

View File

@ -0,0 +1,108 @@
defmodule BerrypodWeb.NewsletterController do
use BerrypodWeb, :controller
alias Berrypod.Newsletter
@doc "No-JS fallback for newsletter signup form."
def subscribe(conn, %{"email" => email}) do
ip_hash = hash_ip(conn)
case Newsletter.subscribe(email,
consent_text: "Newsletter signup on website",
ip_hash: ip_hash
) do
{:ok, _} ->
conn
|> put_flash(:info, "Check your inbox to confirm your subscription.")
|> redirect(to: redirect_back(conn))
{:already_confirmed, _} ->
conn
|> put_flash(:info, "You're already subscribed!")
|> redirect(to: redirect_back(conn))
{:error, _} ->
conn
|> put_flash(:error, "Please enter a valid email address.")
|> redirect(to: redirect_back(conn))
end
end
def subscribe(conn, _params) do
conn
|> put_flash(:error, "Please enter your email address.")
|> redirect(to: ~p"/")
end
@doc "Handles confirmation link from the double opt-in email."
def confirm(conn, %{"token" => token}) do
case Newsletter.confirm(token) do
{:ok, _sub} ->
conn
|> put_status(200)
|> html(confirmation_html(:success))
{:error, :invalid_token} ->
conn
|> put_status(400)
|> html(confirmation_html(:invalid))
end
end
defp confirmation_html(:success) do
shop_name = Berrypod.Settings.get_setting("shop_name", "Berrypod")
"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Subscription confirmed</title>
<style>body{font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;padding:0 24px;color:#111}</style>
</head>
<body>
<h1 style="font-size:1.25rem;margin-bottom:0.75rem">You're subscribed!</h1>
<p style="color:#555">Thanks for confirming. You'll receive the #{shop_name} newsletter from now on.</p>
<p style="margin-top:1.5rem"><a href="/" style="color:#111">Back to the shop</a></p>
</body>
</html>
"""
end
defp confirmation_html(:invalid) do
"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Invalid link</title>
<style>body{font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;padding:0 24px;color:#111}</style>
</head>
<body>
<h1 style="font-size:1.25rem;margin-bottom:0.75rem">Link invalid or expired</h1>
<p style="color:#555">This confirmation link has expired or is invalid. Please try subscribing again.</p>
<p style="margin-top:1.5rem"><a href="/" style="color:#111">Back to the shop</a></p>
</body>
</html>
"""
end
defp redirect_back(conn) do
case get_req_header(conn, "referer") do
[referer | _] ->
uri = URI.parse(referer)
uri.path || "/"
_ ->
"/"
end
end
defp hash_ip(conn) do
daily_salt = Date.utc_today() |> Date.to_iso8601()
ip_string = conn.remote_ip |> :inet.ntoa() |> to_string()
:crypto.hash(:sha256, ip_string <> daily_salt) |> Base.encode16(case: :lower)
end
end

View File

@ -0,0 +1,16 @@
defmodule BerrypodWeb.NewsletterExportController do
use BerrypodWeb, :controller
alias Berrypod.Newsletter
def export(conn, _params) do
csv = Newsletter.export_all_subscribers_csv()
today = Date.to_iso8601(Date.utc_today())
filename = "newsletter-subscribers-#{today}.csv"
conn
|> put_resp_content_type("text/csv")
|> put_resp_header("content-disposition", ~s(attachment; filename="#{filename}"))
|> send_resp(200, csv)
end
end

View File

@ -1,7 +1,7 @@
defmodule BerrypodWeb.UnsubscribeController do defmodule BerrypodWeb.UnsubscribeController do
use BerrypodWeb, :controller use BerrypodWeb, :controller
alias Berrypod.Orders alias Berrypod.{Newsletter, Orders}
# Unsubscribe links should be long-lived — use 2 years # Unsubscribe links should be long-lived — use 2 years
@max_age 2 * 365 * 24 * 3600 @max_age 2 * 365 * 24 * 3600
@ -10,6 +10,7 @@ defmodule BerrypodWeb.UnsubscribeController do
case Phoenix.Token.verify(BerrypodWeb.Endpoint, "email-unsub", token, max_age: @max_age) do case Phoenix.Token.verify(BerrypodWeb.Endpoint, "email-unsub", token, max_age: @max_age) do
{:ok, email} -> {:ok, email} ->
Orders.add_suppression(email, "unsubscribed") Orders.add_suppression(email, "unsubscribed")
Newsletter.unsubscribe(email)
conn conn
|> put_status(200) |> put_status(200)
@ -24,7 +25,7 @@ defmodule BerrypodWeb.UnsubscribeController do
</head> </head>
<body> <body>
<h1 style="font-size:1.25rem;margin-bottom:0.75rem">You've been unsubscribed</h1> <h1 style="font-size:1.25rem;margin-bottom:0.75rem">You've been unsubscribed</h1>
<p style="color:#555">We've removed #{email} from our marketing list. You won't receive any more cart recovery emails from us.</p> <p style="color:#555">We've removed #{email} from our marketing emails. You won't hear from us again.</p>
</body> </body>
</html> </html>
""") """)

View File

@ -0,0 +1,501 @@
defmodule BerrypodWeb.Admin.Newsletter do
use BerrypodWeb, :live_view
alias Berrypod.Newsletter
@impl true
def mount(_params, _session, socket) do
counts = Newsletter.count_subscribers_by_status()
subscribers = Newsletter.list_subscribers()
campaigns = Newsletter.list_campaigns()
{:ok,
socket
|> assign(:page_title, "Newsletter")
|> assign(:tab, "overview")
|> assign(:newsletter_enabled, Newsletter.newsletter_enabled?())
|> assign(:status_counts, counts)
|> assign(:subscriber_count, length(subscribers))
|> assign(:campaign_count, length(campaigns))
|> assign(:status_filter, "all")
|> assign(:search, "")
|> stream(:subscribers, subscribers)
|> stream(:campaigns, campaigns)}
end
@impl true
def handle_params(%{"tab" => tab}, _uri, socket)
when tab in ~w(overview subscribers campaigns) do
socket = assign(socket, :tab, tab)
socket =
case tab do
"subscribers" ->
subscribers =
Newsletter.list_subscribers(
status: socket.assigns.status_filter,
search: socket.assigns.search
)
socket
|> assign(:subscriber_count, length(subscribers))
|> stream(:subscribers, subscribers, reset: true)
"campaigns" ->
campaigns = Newsletter.list_campaigns()
socket
|> assign(:campaign_count, length(campaigns))
|> stream(:campaigns, campaigns, reset: true)
_ ->
socket
end
{:noreply, socket}
end
def handle_params(_params, _uri, socket), do: {:noreply, socket}
@impl true
def handle_event("toggle_enabled", _params, socket) do
new_value = !socket.assigns.newsletter_enabled
Newsletter.set_newsletter_enabled(new_value)
msg = if new_value, do: "Newsletter signups enabled", else: "Newsletter signups disabled"
{:noreply,
socket
|> assign(:newsletter_enabled, new_value)
|> put_flash(:info, msg)}
end
def handle_event("filter_subscribers", %{"status" => status}, socket) do
subscribers = Newsletter.list_subscribers(status: status, search: socket.assigns.search)
{:noreply,
socket
|> assign(:status_filter, status)
|> assign(:subscriber_count, length(subscribers))
|> stream(:subscribers, subscribers, reset: true)}
end
def handle_event("search_subscribers", %{"search" => term}, socket) do
subscribers = Newsletter.list_subscribers(status: socket.assigns.status_filter, search: term)
{:noreply,
socket
|> assign(:search, term)
|> assign(:subscriber_count, length(subscribers))
|> stream(:subscribers, subscribers, reset: true)}
end
def handle_event("delete_subscriber", %{"id" => id}, socket) do
sub = Newsletter.get_subscriber!(id)
{:ok, _} = Newsletter.delete_subscriber(sub)
counts = Newsletter.count_subscribers_by_status()
{:noreply,
socket
|> assign(:status_counts, counts)
|> assign(:subscriber_count, socket.assigns.subscriber_count - 1)
|> stream_delete(:subscribers, sub)
|> put_flash(:info, "Subscriber deleted")}
end
def handle_event("delete_campaign", %{"id" => id}, socket) do
campaign = Newsletter.get_campaign!(id)
case Newsletter.delete_campaign(campaign) do
{:ok, _} ->
{:noreply,
socket
|> assign(:campaign_count, socket.assigns.campaign_count - 1)
|> stream_delete(:campaigns, campaign)
|> put_flash(:info, "Campaign deleted")}
{:error, :not_draft} ->
{:noreply, put_flash(socket, :error, "Only draft campaigns can be deleted")}
end
end
@impl true
def render(assigns) do
~H"""
<.header>
Newsletter
<:subtitle>Manage subscribers and email campaigns</:subtitle>
</.header>
<div class="flex gap-2 mt-6 mb-6 border-b border-base-200">
<.tab_link label="Overview" tab="overview" active={@tab} />
<.tab_link
label="Subscribers"
tab="subscribers"
active={@tab}
count={total_subs(@status_counts)}
/>
<.tab_link label="Campaigns" tab="campaigns" active={@tab} count={@campaign_count} />
</div>
<div :if={@tab == "overview"}>
<.overview_tab
newsletter_enabled={@newsletter_enabled}
status_counts={@status_counts}
/>
</div>
<div :if={@tab == "subscribers"}>
<.subscribers_tab
streams={@streams}
status_filter={@status_filter}
status_counts={@status_counts}
subscriber_count={@subscriber_count}
search={@search}
/>
</div>
<div :if={@tab == "campaigns"}>
<.campaigns_tab streams={@streams} campaign_count={@campaign_count} />
</div>
"""
end
# ── Tab navigation ──────────────────────────────────────────────
attr :label, :string, required: true
attr :tab, :string, required: true
attr :active, :string, required: true
attr :count, :integer, default: nil
defp tab_link(assigns) do
~H"""
<.link
patch={~p"/admin/newsletter?tab=#{@tab}"}
class={[
"px-3 py-2 text-sm font-medium border-b-2 -mb-px",
if(@tab == @active,
do: "border-base-content text-base-content",
else:
"border-transparent text-base-content/60 hover:text-base-content hover:border-base-300"
)
]}
>
{@label}
<span :if={@count} class="ml-1 text-xs text-base-content/40">{@count}</span>
</.link>
"""
end
# ── Overview tab ────────────────────────────────────────────────
attr :newsletter_enabled, :boolean, required: true
attr :status_counts, :map, required: true
defp overview_tab(assigns) do
~H"""
<div class="space-y-6">
<div class="flex items-center gap-4 p-4 rounded-lg border border-base-200">
<div class="flex-1">
<h3 class="font-medium">Newsletter signups</h3>
<p class="text-sm text-base-content/60 mt-0.5">
When enabled, the newsletter signup block on your shop pages will collect email addresses with double opt-in confirmation.
</p>
</div>
<button
phx-click="toggle_enabled"
class={[
"relative inline-flex h-6 w-12 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
if(@newsletter_enabled, do: "bg-green-600", else: "bg-base-300")
]}
role="switch"
aria-checked={to_string(@newsletter_enabled)}
aria-label="Toggle newsletter signups"
>
<span
class="pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform"
style={"transform: translateX(#{if @newsletter_enabled, do: "1.5rem", else: "0"})"}
/>
</button>
</div>
<div class="grid grid-cols-3 gap-4">
<div class="p-4 rounded-lg border border-base-200 text-center">
<p class="text-2xl font-bold">{@status_counts["confirmed"] || 0}</p>
<p class="text-sm text-base-content/60">Confirmed</p>
</div>
<div class="p-4 rounded-lg border border-base-200 text-center">
<p class="text-2xl font-bold">{@status_counts["pending"] || 0}</p>
<p class="text-sm text-base-content/60">Pending</p>
</div>
<div class="p-4 rounded-lg border border-base-200 text-center">
<p class="text-2xl font-bold">{@status_counts["unsubscribed"] || 0}</p>
<p class="text-sm text-base-content/60">Unsubscribed</p>
</div>
</div>
<div class="flex gap-3">
<.link navigate={~p"/admin/newsletter?tab=subscribers"} class="text-sm font-medium underline">
View subscribers
</.link>
<.link navigate={~p"/admin/newsletter?tab=campaigns"} class="text-sm font-medium underline">
View campaigns
</.link>
</div>
</div>
"""
end
# ── Subscribers tab ─────────────────────────────────────────────
attr :streams, :any, required: true
attr :status_filter, :string, required: true
attr :status_counts, :map, required: true
attr :subscriber_count, :integer, required: true
attr :search, :string, required: true
defp subscribers_tab(assigns) do
~H"""
<div>
<div class="flex items-center justify-between mb-4">
<div class="flex gap-2 flex-wrap">
<.filter_pill
status="all"
label="All"
count={total_subs(@status_counts)}
active={@status_filter}
/>
<.filter_pill
status="confirmed"
label="Confirmed"
count={@status_counts["confirmed"]}
active={@status_filter}
/>
<.filter_pill
status="pending"
label="Pending"
count={@status_counts["pending"]}
active={@status_filter}
/>
<.filter_pill
status="unsubscribed"
label="Unsubscribed"
count={@status_counts["unsubscribed"]}
active={@status_filter}
/>
</div>
<.link href={~p"/admin/newsletter/export"} class="admin-btn admin-btn-sm admin-btn-ghost">
<.icon name="hero-arrow-down-tray" class="size-4" /> Export CSV
</.link>
</div>
<form phx-change="search_subscribers" class="mb-4">
<.input
name="search"
value={@search}
type="search"
placeholder="Search by email..."
phx-debounce="300"
/>
</form>
<.table
:if={@subscriber_count > 0}
id="subscribers"
rows={@streams.subscribers}
row_item={fn {_id, sub} -> sub end}
>
<:col :let={sub} label="Email">{sub.email}</:col>
<:col :let={sub} label="Status"><.subscriber_status status={sub.status} /></:col>
<:col :let={sub} label="Subscribed">{format_date(sub.inserted_at)}</:col>
<:col :let={sub} label="Source">{sub.source}</:col>
<:action :let={sub}>
<button
phx-click="delete_subscriber"
phx-value-id={sub.id}
data-confirm="Permanently delete this subscriber? This cannot be undone."
class="text-sm text-red-600 hover:text-red-800"
>
Delete
</button>
</:action>
</.table>
<div :if={@subscriber_count == 0} class="text-center py-12 text-base-content/60">
<.icon name="hero-envelope" class="size-12 mx-auto mb-4" />
<p class="text-lg font-medium">No subscribers yet</p>
<p class="text-sm mt-1">Subscribers will appear here when people sign up via your shop.</p>
</div>
</div>
"""
end
# ── Campaigns tab ───────────────────────────────────────────────
attr :streams, :any, required: true
attr :campaign_count, :integer, required: true
defp campaigns_tab(assigns) do
~H"""
<div>
<div class="flex justify-end mb-4">
<.link navigate={~p"/admin/newsletter/campaigns/new"} class="admin-btn admin-btn-primary">
<.icon name="hero-plus" class="size-4" /> New campaign
</.link>
</div>
<.table
:if={@campaign_count > 0}
id="campaigns"
rows={@streams.campaigns}
row_item={fn {_id, c} -> c end}
row_click={fn {_id, c} -> JS.navigate(~p"/admin/newsletter/campaigns/#{c.id}") end}
>
<:col :let={c} label="Subject">{c.subject}</:col>
<:col :let={c} label="Status"><.campaign_status status={c.status} /></:col>
<:col :let={c} label="Sent">{c.sent_count}</:col>
<:col :let={c} label="Created">{format_date(c.inserted_at)}</:col>
<:action :let={c}>
<button
:if={c.status == "draft"}
phx-click="delete_campaign"
phx-value-id={c.id}
data-confirm="Delete this draft campaign?"
class="text-sm text-red-600 hover:text-red-800"
>
Delete
</button>
</:action>
</.table>
<div :if={@campaign_count == 0} class="text-center py-12 text-base-content/60">
<.icon name="hero-megaphone" class="size-12 mx-auto mb-4" />
<p class="text-lg font-medium">No campaigns yet</p>
<p class="text-sm mt-1">Create your first campaign to reach your subscribers.</p>
</div>
</div>
"""
end
# ── Components ──────────────────────────────────────────────────
attr :status, :string, required: true
attr :label, :string, required: true
attr :count, :integer, default: nil
attr :active, :string, required: true
defp filter_pill(assigns) do
count = assigns[:count] || 0
active = assigns.status == assigns.active
assigns = assign(assigns, count: count, active: active)
~H"""
<button
phx-click="filter_subscribers"
phx-value-status={@status}
class={[
"admin-btn admin-btn-sm",
@active && "admin-btn-primary",
!@active && "admin-btn-ghost"
]}
>
{@label}
<span :if={@count > 0} class="admin-badge admin-badge-sm ml-1">{@count}</span>
</button>
"""
end
attr :status, :string, required: true
defp subscriber_status(%{status: "confirmed"} = assigns) do
~H"""
<span class="inline-flex items-center gap-1 text-sm text-green-700">
<span class="w-1.5 h-1.5 rounded-full bg-green-500"></span> Confirmed
</span>
"""
end
defp subscriber_status(%{status: "pending"} = assigns) do
~H"""
<span class="inline-flex items-center gap-1 text-sm text-amber-700">
<span class="w-1.5 h-1.5 rounded-full bg-amber-500"></span> Pending
</span>
"""
end
defp subscriber_status(%{status: "unsubscribed"} = assigns) do
~H"""
<span class="inline-flex items-center gap-1 text-sm text-base-content/50">
<span class="w-1.5 h-1.5 rounded-full bg-base-300"></span> Unsubscribed
</span>
"""
end
defp subscriber_status(assigns) do
~H"""
<span class="text-sm text-base-content/50">{@status}</span>
"""
end
attr :status, :string, required: true
defp campaign_status(%{status: "draft"} = assigns) do
~H"""
<span class="inline-flex items-center gap-1.5 text-sm text-base-content/60">
<span class="w-1.5 h-1.5 rounded-full bg-base-300"></span> Draft
</span>
"""
end
defp campaign_status(%{status: "scheduled"} = assigns) do
~H"""
<span class="inline-flex items-center gap-1.5 text-sm text-blue-700">
<span class="w-1.5 h-1.5 rounded-full bg-blue-500"></span> Scheduled
</span>
"""
end
defp campaign_status(%{status: "sending"} = assigns) do
~H"""
<span class="inline-flex items-center gap-1.5 text-sm text-amber-700">
<span class="w-1.5 h-1.5 rounded-full bg-amber-500"></span> Sending
</span>
"""
end
defp campaign_status(%{status: "sent"} = assigns) do
~H"""
<span class="inline-flex items-center gap-1.5 text-sm text-green-700">
<span class="w-1.5 h-1.5 rounded-full bg-green-500"></span> Sent
</span>
"""
end
defp campaign_status(%{status: "cancelled"} = assigns) do
~H"""
<span class="inline-flex items-center gap-1.5 text-sm text-red-700">
<span class="w-1.5 h-1.5 rounded-full bg-red-500"></span> Cancelled
</span>
"""
end
defp campaign_status(assigns) do
~H"""
<span class="text-sm text-base-content/50">{@status}</span>
"""
end
# ── Helpers ─────────────────────────────────────────────────────
defp total_subs(counts) do
Enum.reduce(counts, 0, fn {_status, count}, acc -> acc + count end)
end
defp format_date(nil), do: ""
defp format_date(datetime) do
Calendar.strftime(datetime, "%-d %b %Y")
end
end

View File

@ -0,0 +1,236 @@
defmodule BerrypodWeb.Admin.Newsletter.CampaignForm do
use BerrypodWeb, :live_view
alias Berrypod.Newsletter
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "New campaign")
|> assign(:campaign, nil)
|> assign(:subscriber_count, Newsletter.confirmed_subscriber_count())
|> assign(:form, to_form(%{"subject" => "", "body" => ""}, as: :campaign))}
end
@impl true
def handle_params(%{"id" => id}, _uri, socket) do
campaign = Newsletter.get_campaign!(id)
title =
if campaign.status == "draft",
do: "Edit campaign",
else: "Campaign: #{campaign.subject}"
{:noreply,
socket
|> assign(:page_title, title)
|> assign(:campaign, campaign)
|> assign(
:form,
to_form(
%{
"subject" => campaign.subject,
"body" => campaign.body
},
as: :campaign
)
)}
end
def handle_params(_params, _uri, socket), do: {:noreply, socket}
@impl true
def handle_event("validate", %{"campaign" => params}, socket) do
{:noreply, assign(socket, :form, to_form(params, as: :campaign))}
end
def handle_event("save_draft", %{"campaign" => params}, socket) do
case save_campaign(socket.assigns.campaign, params) do
{:ok, campaign} ->
{:noreply,
socket
|> assign(:campaign, campaign)
|> put_flash(:info, "Campaign saved")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Please fill in subject and body")}
end
end
def handle_event("send_now", _params, socket) do
params = current_form_params(socket)
with {:ok, campaign} <- save_campaign(socket.assigns.campaign, params),
{:ok, campaign} <- Newsletter.send_campaign_now(campaign) do
{:noreply,
socket
|> assign(:campaign, campaign)
|> put_flash(:info, "Campaign is being sent!")
|> push_navigate(to: ~p"/admin/newsletter?tab=campaigns")}
else
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to send campaign")}
end
end
def handle_event("schedule", _params, socket) do
params = current_form_params(socket)
scheduled_at = parse_schedule_time(params["scheduled_at"])
with {:ok, campaign} <- save_campaign(socket.assigns.campaign, params),
{:ok, _campaign} <- Newsletter.schedule_campaign(campaign, scheduled_at) do
{:noreply,
socket
|> put_flash(:info, "Campaign scheduled")
|> push_navigate(to: ~p"/admin/newsletter?tab=campaigns")}
else
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to schedule campaign")}
end
end
defp save_campaign(nil, params) do
Newsletter.create_campaign(%{
subject: params["subject"],
body: params["body"]
})
end
defp save_campaign(%{status: "draft"} = campaign, params) do
Newsletter.update_campaign(campaign, %{
subject: params["subject"],
body: params["body"]
})
end
defp save_campaign(campaign, _params), do: {:ok, campaign}
defp parse_schedule_time(nil), do: DateTime.utc_now() |> DateTime.add(3600)
defp parse_schedule_time(""), do: DateTime.utc_now() |> DateTime.add(3600)
defp parse_schedule_time(str) do
case DateTime.from_iso8601(str <> ":00Z") do
{:ok, dt, _} -> dt
_ -> DateTime.utc_now() |> DateTime.add(3600)
end
end
@impl true
def render(assigns) do
~H"""
<.header>
{@page_title}
<:subtitle>
<%= cond do %>
<% readonly?(@campaign) -> %>
This campaign was sent on {format_date(@campaign.sent_at)} to {@campaign.sent_count} subscribers
<% @subscriber_count > 0 -> %>
{@subscriber_count} confirmed subscribers will receive this email
<% true -> %>
No confirmed subscribers yet
<% end %>
</:subtitle>
</.header>
<div class="mt-6 max-w-2xl">
<.form for={@form} phx-change="validate" phx-submit="save_draft">
<div class="space-y-4">
<.input
field={@form[:subject]}
type="text"
label="Subject"
required
disabled={readonly?(@campaign)}
placeholder="Your campaign subject"
/>
<.input
field={@form[:body]}
type="textarea"
label="Body (plain text)"
required
disabled={readonly?(@campaign)}
rows="12"
placeholder="Hello!\n\nYour newsletter content here.\n\nUnsubscribe: {{unsubscribe_url}}"
/>
<p :if={!readonly?(@campaign)} class="text-sm text-base-content/60">
Use <code>{"{{unsubscribe_url}}"}</code>
to insert the unsubscribe link. This is required for GDPR compliance.
</p>
<p
:if={missing_unsubscribe_url?(@form[:body].value) && !readonly?(@campaign)}
class="flex items-center gap-2 text-sm text-amber-700"
>
<.icon name="hero-exclamation-triangle" class="size-4 shrink-0" /> Body is missing
<code>{"{{unsubscribe_url}}"}</code>
this is required for GDPR compliance.
</p>
<%= if @form[:body].value && @form[:body].value != "" do %>
<details class="mt-4">
<summary class="text-sm font-medium cursor-pointer">Preview</summary>
<pre class="mt-2 p-4 bg-base-200 rounded-lg text-sm whitespace-pre-wrap overflow-auto max-h-64">{preview_body(@form[:body].value)}</pre>
</details>
<% end %>
<div
:if={!readonly?(@campaign)}
class="flex items-center gap-3 pt-4 border-t border-base-200"
>
<.button type="submit">
Save draft
</.button>
<button
type="button"
phx-click="send_now"
data-confirm={"Send this campaign to #{@subscriber_count} subscribers now?"}
class="admin-btn admin-btn-primary"
style="background-color: var(--color-green-600)"
disabled={@subscriber_count == 0}
>
<.icon name="hero-paper-airplane" class="size-4" /> Send now
</button>
<.link
navigate={~p"/admin/newsletter?tab=campaigns"}
class="admin-btn admin-btn-ghost"
>
Cancel
</.link>
</div>
<div :if={readonly?(@campaign)} class="pt-4 border-t border-base-200">
<.link navigate={~p"/admin/newsletter?tab=campaigns"} class="admin-btn admin-btn-ghost">
<.icon name="hero-arrow-left" class="size-4" /> Back to campaigns
</.link>
</div>
</div>
</.form>
</div>
"""
end
defp current_form_params(socket) do
form = socket.assigns.form
%{"subject" => form[:subject].value || "", "body" => form[:body].value || ""}
end
defp readonly?(%{status: status}) when status not in ["draft"], do: true
defp readonly?(_), do: false
defp missing_unsubscribe_url?(nil), do: false
defp missing_unsubscribe_url?(""), do: false
defp missing_unsubscribe_url?(body), do: not String.contains?(body, "{{unsubscribe_url}}")
defp format_date(nil), do: ""
defp format_date(datetime), do: Calendar.strftime(datetime, "%-d %b %Y")
defp preview_body(body) do
sample_url = BerrypodWeb.Endpoint.url() <> "/unsubscribe/sample-token"
String.replace(body, "{{unsubscribe_url}}", sample_url)
end
end

View File

@ -0,0 +1,60 @@
defmodule BerrypodWeb.NewsletterHook do
@moduledoc """
LiveView on_mount hook for newsletter signup across all shop pages.
Uses `attach_hook/4` to intercept `newsletter_subscribe` events globally
without modifying individual shop LiveViews.
"""
import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [attach_hook: 4]
alias Berrypod.Newsletter
def on_mount(:mount_newsletter, _params, _session, socket) do
enabled = Newsletter.newsletter_enabled?()
socket =
socket
|> assign(:newsletter_enabled, enabled)
|> assign(:newsletter_state, :idle)
|> assign(:newsletter_ip_hash, hash_ip(socket))
|> attach_hook(:newsletter, :handle_event, &handle_newsletter_event/3)
{:cont, socket}
end
defp handle_newsletter_event("newsletter_subscribe", %{"email" => email}, socket) do
if socket.assigns.newsletter_enabled do
case Newsletter.subscribe(email,
consent_text: "Newsletter signup on website",
ip_hash: socket.assigns.newsletter_ip_hash
) do
{:ok, _} ->
{:halt, assign(socket, :newsletter_state, :submitted)}
{:already_confirmed, _} ->
{:halt, assign(socket, :newsletter_state, :submitted)}
{:error, _} ->
{:halt, assign(socket, :newsletter_state, :error)}
end
else
{:halt, assign(socket, :newsletter_state, :disabled)}
end
end
defp handle_newsletter_event(_event, _params, socket), do: {:cont, socket}
# connect_info is only available during mount, so we hash and store it early
defp hash_ip(socket) do
case Phoenix.LiveView.get_connect_info(socket, :peer_data) do
%{address: ip} ->
daily_salt = Date.utc_today() |> Date.to_iso8601()
:crypto.hash(:sha256, :inet.ntoa(ip) ++ [daily_salt]) |> Base.encode16(case: :lower)
_ ->
nil
end
end
end

View File

@ -310,8 +310,18 @@ defmodule BerrypodWeb.PageRenderer do
|> assign(:title, settings["title"] || "Newsletter") |> assign(:title, settings["title"] || "Newsletter")
|> assign(:description, settings["description"] || "") |> assign(:description, settings["description"] || "")
|> assign(:button_text, settings["button_text"] || "Subscribe") |> assign(:button_text, settings["button_text"] || "Subscribe")
|> assign(:newsletter_state, assigns[:newsletter_state] || :idle)
|> assign(:newsletter_enabled, assigns[:newsletter_enabled] || false)
~H"<.newsletter_card title={@title} description={@description} button_text={@button_text} />" ~H"""
<.newsletter_card
title={@title}
description={@description}
button_text={@button_text}
newsletter_state={@newsletter_state}
newsletter_enabled={@newsletter_enabled}
/>
"""
end end
defp render_block(%{block: %{"type" => "social_links_card"}} = assigns) do defp render_block(%{block: %{"type" => "social_links_card"}} = assigns) do

View File

@ -136,6 +136,7 @@ defmodule BerrypodWeb.Router do
pipe_through [:browser, :require_authenticated_user, :admin] pipe_through [:browser, :require_authenticated_user, :admin]
get "/analytics/export", AnalyticsExportController, :export get "/analytics/export", AnalyticsExportController, :export
get "/newsletter/export", NewsletterExportController, :export
live_session :admin, live_session :admin,
layout: {BerrypodWeb.Layouts, :admin}, layout: {BerrypodWeb.Layouts, :admin},
@ -160,6 +161,9 @@ defmodule BerrypodWeb.Router do
live "/pages/:slug", Admin.Pages.Editor, :edit live "/pages/:slug", Admin.Pages.Editor, :edit
live "/navigation", Admin.Navigation, :index live "/navigation", Admin.Navigation, :index
live "/media", Admin.Media, :index live "/media", Admin.Media, :index
live "/newsletter", Admin.Newsletter, :index
live "/newsletter/campaigns/new", Admin.Newsletter.CampaignForm, :new
live "/newsletter/campaigns/:id", Admin.Newsletter.CampaignForm, :edit
live "/redirects", Admin.Redirects, :index live "/redirects", Admin.Redirects, :index
end end
@ -203,6 +207,7 @@ defmodule BerrypodWeb.Router do
get "/orders/verify/:token", OrderLookupController, :verify get "/orders/verify/:token", OrderLookupController, :verify
get "/unsubscribe/:token", UnsubscribeController, :unsubscribe get "/unsubscribe/:token", UnsubscribeController, :unsubscribe
get "/newsletter/confirm/:token", NewsletterController, :confirm
end end
# Dev-only routes (mailbox preview, error previews) # Dev-only routes (mailbox preview, error previews)
@ -247,7 +252,8 @@ defmodule BerrypodWeb.Router do
{BerrypodWeb.CartHook, :mount_cart}, {BerrypodWeb.CartHook, :mount_cart},
{BerrypodWeb.SearchHook, :mount_search}, {BerrypodWeb.SearchHook, :mount_search},
{BerrypodWeb.AnalyticsHook, :track}, {BerrypodWeb.AnalyticsHook, :track},
{BerrypodWeb.PageEditorHook, :mount_page_editor} {BerrypodWeb.PageEditorHook, :mount_page_editor},
{BerrypodWeb.NewsletterHook, :mount_newsletter}
] do ] do
live "/", Shop.Home, :index live "/", Shop.Home, :index
live "/about", Shop.Content, :about live "/about", Shop.Content, :about
@ -274,6 +280,9 @@ defmodule BerrypodWeb.Router do
post "/contact/send", ContactController, :create post "/contact/send", ContactController, :create
post "/contact/lookup", OrderLookupController, :lookup post "/contact/lookup", OrderLookupController, :lookup
# Newsletter signup (no-JS fallback)
post "/newsletter/subscribe", NewsletterController, :subscribe
# Cart form actions (no-JS fallbacks for LiveView cart events) # Cart form actions (no-JS fallbacks for LiveView cart events)
post "/cart/add", CartController, :add post "/cart/add", CartController, :add
post "/cart/remove", CartController, :remove post "/cart/remove", CartController, :remove

View File

@ -0,0 +1,39 @@
defmodule Berrypod.Repo.Migrations.CreateNewsletter do
use Ecto.Migration
def change do
create table(:newsletter_subscribers, primary_key: false) do
add :id, :binary_id, primary_key: true
add :email, :string, null: false
add :status, :string, null: false, default: "pending"
add :confirmation_token, :string
add :confirmed_at, :utc_datetime
add :unsubscribed_at, :utc_datetime
add :consent_text, :string
add :source, :string, default: "website"
add :ip_hash, :string
timestamps(type: :utc_datetime)
end
create unique_index(:newsletter_subscribers, [:email])
create index(:newsletter_subscribers, [:status])
create index(:newsletter_subscribers, [:confirmation_token])
create table(:newsletter_campaigns, primary_key: false) do
add :id, :binary_id, primary_key: true
add :subject, :string, null: false
add :body, :text, null: false
add :status, :string, null: false, default: "draft"
add :scheduled_at, :utc_datetime
add :sent_at, :utc_datetime
add :sent_count, :integer, default: 0
add :failed_count, :integer, default: 0
timestamps(type: :utc_datetime)
end
create index(:newsletter_campaigns, [:status])
create index(:newsletter_campaigns, [:scheduled_at])
end
end

View File

@ -0,0 +1,50 @@
defmodule Berrypod.Newsletter.CampaignSendWorkerTest do
use Berrypod.DataCase, async: true
use Oban.Testing, repo: Berrypod.Repo
alias Berrypod.Newsletter
alias Berrypod.Newsletter.CampaignSendWorker
import Berrypod.NewsletterFixtures
describe "perform/1" do
test "sends campaign to confirmed subscribers" do
confirmed_subscriber_fixture(email: "a@example.com")
confirmed_subscriber_fixture(email: "b@example.com")
campaign = campaign_fixture(subject: "Hello!", body: "Test body {{unsubscribe_url}}")
{:ok, campaign} = Newsletter.update_campaign(campaign, %{status: "sending"})
assert :ok = perform_job(CampaignSendWorker, %{campaign_id: campaign.id})
campaign = Newsletter.get_campaign!(campaign.id)
assert campaign.status == "sent"
assert campaign.sent_count == 2
assert campaign.failed_count == 0
assert campaign.sent_at != nil
end
test "only sends to confirmed subscribers" do
confirmed_subscriber_fixture(email: "active@example.com")
# Create an unsubscribed subscriber — should be skipped
sub = confirmed_subscriber_fixture(email: "unsub@example.com")
Newsletter.unsubscribe(sub.email)
campaign = campaign_fixture()
{:ok, campaign} = Newsletter.update_campaign(campaign, %{status: "sending"})
assert :ok = perform_job(CampaignSendWorker, %{campaign_id: campaign.id})
campaign = Newsletter.get_campaign!(campaign.id)
assert campaign.sent_count == 1
end
test "skips campaigns with wrong status" do
campaign = campaign_fixture()
{:ok, campaign} = Newsletter.update_campaign(campaign, %{status: "sent"})
assert :ok = perform_job(CampaignSendWorker, %{campaign_id: campaign.id})
end
end
end

View File

@ -0,0 +1,48 @@
defmodule Berrypod.Newsletter.CleanupWorkerTest do
use Berrypod.DataCase, async: true
use Oban.Testing, repo: Berrypod.Repo
alias Berrypod.Newsletter.{CleanupWorker, Subscriber}
alias Berrypod.Repo
import Berrypod.NewsletterFixtures
describe "perform/1" do
test "prunes pending subscribers older than 48 hours" do
# Insert an old pending subscriber directly
old_time = DateTime.utc_now() |> DateTime.add(-49 * 3600) |> DateTime.truncate(:second)
%Subscriber{}
|> Subscriber.changeset(%{email: "old@example.com", status: "pending"})
|> Ecto.Changeset.force_change(:inserted_at, old_time)
|> Repo.insert!()
# Insert a fresh pending subscriber
%Subscriber{}
|> Subscriber.changeset(%{email: "fresh@example.com", status: "pending"})
|> Repo.insert!()
assert :ok = perform_job(CleanupWorker, %{})
# Old pending subscriber should be deleted
assert Repo.get_by(Subscriber, email: "old@example.com") == nil
# Fresh one should remain
assert Repo.get_by(Subscriber, email: "fresh@example.com") != nil
end
test "does not prune confirmed subscribers" do
old_time = DateTime.utc_now() |> DateTime.add(-49 * 3600) |> DateTime.truncate(:second)
confirmed = confirmed_subscriber_fixture(email: "confirmed@example.com")
# Backdate the subscriber
confirmed
|> Ecto.Changeset.change(inserted_at: old_time)
|> Repo.update!()
assert :ok = perform_job(CleanupWorker, %{})
assert Repo.get_by(Subscriber, email: "confirmed@example.com") != nil
end
end
end

View File

@ -0,0 +1,45 @@
defmodule Berrypod.Newsletter.ConfirmationEmailWorkerTest do
use Berrypod.DataCase, async: true
use Oban.Testing, repo: Berrypod.Repo
alias Berrypod.Newsletter.ConfirmationEmailWorker
import Berrypod.NewsletterFixtures
describe "perform/1" do
test "sends confirmation email for pending subscriber" do
# Create subscriber in manual mode so the confirmation job doesn't auto-run
Oban.Testing.with_testing_mode(:manual, fn ->
{:ok, sub} = Berrypod.Newsletter.subscribe("test@example.com")
[job] = all_enqueued(worker: ConfirmationEmailWorker)
raw_token = job.args["raw_token"]
# Now perform the job (uses Swoosh.Adapters.Local in test)
assert :ok =
perform_job(ConfirmationEmailWorker, %{
subscriber_id: sub.id,
raw_token: raw_token
})
end)
end
test "skips if subscriber is no longer pending" do
sub = confirmed_subscriber_fixture()
assert :ok =
perform_job(ConfirmationEmailWorker, %{
subscriber_id: sub.id,
raw_token: "irrelevant"
})
end
test "cancels if subscriber not found" do
assert {:cancel, :not_found} =
perform_job(ConfirmationEmailWorker, %{
subscriber_id: Ecto.UUID.generate(),
raw_token: "irrelevant"
})
end
end
end

View File

@ -0,0 +1,316 @@
defmodule Berrypod.NewsletterTest do
use Berrypod.DataCase, async: true
use Oban.Testing, repo: Berrypod.Repo
alias Berrypod.Newsletter
alias Berrypod.Newsletter.{Campaign, Subscriber}
import Berrypod.NewsletterFixtures
# ── Subscribe flow ───────────────────────────────────────────────
describe "subscribe/2" do
test "creates a pending subscriber" do
assert {:ok, %Subscriber{status: "pending"}} =
Newsletter.subscribe("test@example.com")
end
test "normalises email to lowercase" do
{:ok, sub} = Newsletter.subscribe("Test@EXAMPLE.com")
assert sub.email == "test@example.com"
end
test "stores consent text and source" do
{:ok, sub} =
Newsletter.subscribe("test@example.com",
consent_text: "Stay updated",
source: "website"
)
assert sub.consent_text == "Stay updated"
assert sub.source == "website"
end
test "stores hashed confirmation token" do
{:ok, sub} = Newsletter.subscribe("test@example.com")
sub = Repo.get!(Subscriber, sub.id)
assert sub.confirmation_token != nil
# Token is a hex-encoded SHA256 hash (64 chars)
assert byte_size(sub.confirmation_token) == 64
end
test "enqueues confirmation email worker" do
Oban.Testing.with_testing_mode(:manual, fn ->
{:ok, _sub} = Newsletter.subscribe("test@example.com")
assert_enqueued(worker: Berrypod.Newsletter.ConfirmationEmailWorker)
end)
end
test "returns already_confirmed for confirmed subscribers" do
sub = confirmed_subscriber_fixture(email: "test@example.com")
assert {:already_confirmed, ^sub} =
Newsletter.subscribe("test@example.com")
end
test "returns ok for pending subscribers (resends confirmation)" do
{:ok, sub} = Newsletter.subscribe("test@example.com")
assert {:ok, ^sub} = Newsletter.subscribe("test@example.com")
end
test "resubscribes unsubscribed users" do
confirmed_subscriber_fixture(email: "test@example.com")
{:ok, _} = Newsletter.unsubscribe("test@example.com")
{:ok, resub} = Newsletter.subscribe("test@example.com")
assert resub.status == "pending"
assert resub.unsubscribed_at == nil
end
test "rejects invalid email" do
assert {:error, %Ecto.Changeset{}} = Newsletter.subscribe("not-an-email")
end
end
# ── Confirm ──────────────────────────────────────────────────────
describe "confirm/1" do
test "confirms a pending subscriber with valid token" do
Oban.Testing.with_testing_mode(:manual, fn ->
{:ok, _sub} = Newsletter.subscribe("test@example.com")
# Grab the raw token from the enqueued Oban job
[job] = all_enqueued(worker: Berrypod.Newsletter.ConfirmationEmailWorker)
raw_token = job.args["raw_token"]
assert {:ok, confirmed} = Newsletter.confirm(raw_token)
assert confirmed.status == "confirmed"
assert confirmed.confirmed_at != nil
assert confirmed.confirmation_token == nil
end)
end
test "returns error for invalid token" do
assert {:error, :invalid_token} = Newsletter.confirm("bogus-token")
end
test "is idempotent for already confirmed subscribers" do
Oban.Testing.with_testing_mode(:manual, fn ->
{:ok, _sub} = Newsletter.subscribe("test@example.com")
[job] = all_enqueued(worker: Berrypod.Newsletter.ConfirmationEmailWorker)
raw_token = job.args["raw_token"]
{:ok, _} = Newsletter.confirm(raw_token)
# Token is cleared after first confirm, so second call with same token fails
assert {:error, :invalid_token} = Newsletter.confirm(raw_token)
end)
end
end
# ── Unsubscribe ──────────────────────────────────────────────────
describe "unsubscribe/1" do
test "marks subscriber as unsubscribed" do
confirmed_subscriber_fixture(email: "test@example.com")
assert {:ok, sub} = Newsletter.unsubscribe("test@example.com")
assert sub.status == "unsubscribed"
assert sub.unsubscribed_at != nil
end
test "does not add to global suppression list" do
confirmed_subscriber_fixture(email: "test@example.com")
Newsletter.unsubscribe("test@example.com")
assert Berrypod.Orders.check_suppression("test@example.com") == :ok
end
test "is idempotent" do
confirmed_subscriber_fixture(email: "test@example.com")
{:ok, _} = Newsletter.unsubscribe("test@example.com")
assert {:ok, _} = Newsletter.unsubscribe("test@example.com")
end
test "returns error for unknown email" do
assert {:error, :not_found} = Newsletter.unsubscribe("unknown@example.com")
end
end
# ── Subscriber queries ───────────────────────────────────────────
describe "list_subscribers/1" do
test "returns all subscribers" do
confirmed_subscriber_fixture()
confirmed_subscriber_fixture()
result = Newsletter.list_subscribers()
assert length(result) == 2
end
test "filters by status" do
confirmed_subscriber_fixture()
{:ok, _} = Newsletter.subscribe("pending@example.com")
result = Newsletter.list_subscribers(status: "confirmed")
assert length(result) == 1
assert hd(result).status == "confirmed"
end
test "searches by email" do
confirmed_subscriber_fixture(email: "alice@example.com")
confirmed_subscriber_fixture(email: "bob@example.com")
result = Newsletter.list_subscribers(search: "alice")
assert length(result) == 1
assert hd(result).email == "alice@example.com"
end
end
describe "count_subscribers_by_status/0" do
test "returns counts grouped by status" do
confirmed_subscriber_fixture()
confirmed_subscriber_fixture()
{:ok, _} = Newsletter.subscribe("pending@example.com")
counts = Newsletter.count_subscribers_by_status()
assert counts["confirmed"] == 2
assert counts["pending"] == 1
end
end
describe "confirmed_subscriber_count/0" do
test "returns count of confirmed subscribers" do
confirmed_subscriber_fixture()
confirmed_subscriber_fixture()
{:ok, _} = Newsletter.subscribe("pending@example.com")
assert Newsletter.confirmed_subscriber_count() == 2
end
end
describe "delete_subscriber/1" do
test "hard-deletes a subscriber" do
sub = confirmed_subscriber_fixture()
assert {:ok, _} = Newsletter.delete_subscriber(sub)
assert Repo.get(Subscriber, sub.id) == nil
end
end
describe "export_subscriber_data/1" do
test "returns a map of all stored data" do
sub = confirmed_subscriber_fixture(email: "export@example.com")
data = Newsletter.export_subscriber_data(sub)
assert data.email == "export@example.com"
assert data.status == "confirmed"
assert data.subscribed_at != nil
end
end
describe "export_all_subscribers_csv/0" do
test "returns CSV with header and rows" do
confirmed_subscriber_fixture(email: "a@example.com")
confirmed_subscriber_fixture(email: "b@example.com")
csv = Newsletter.export_all_subscribers_csv()
assert csv =~ "email,status,confirmed_at,subscribed_at"
assert csv =~ "a@example.com"
assert csv =~ "b@example.com"
end
end
# ── Campaign CRUD ────────────────────────────────────────────────
describe "campaign CRUD" do
test "creates a draft campaign" do
assert {:ok, %Campaign{status: "draft"}} =
Newsletter.create_campaign(%{
subject: "Test",
body: "Hello {{unsubscribe_url}}"
})
end
test "lists campaigns" do
campaign_fixture(subject: "First")
campaign_fixture(subject: "Second")
result = Newsletter.list_campaigns()
assert length(result) == 2
end
test "updates a campaign" do
campaign = campaign_fixture()
{:ok, updated} = Newsletter.update_campaign(campaign, %{subject: "Updated"})
assert updated.subject == "Updated"
end
test "deletes a draft campaign" do
campaign = campaign_fixture()
assert {:ok, _} = Newsletter.delete_campaign(campaign)
end
test "refuses to delete non-draft campaigns" do
campaign = campaign_fixture()
{:ok, sent} = Newsletter.update_campaign(campaign, %{status: "sent"})
assert {:error, :not_draft} = Newsletter.delete_campaign(sent)
end
end
describe "schedule_campaign/2" do
test "schedules a draft campaign" do
campaign = campaign_fixture()
at = DateTime.utc_now() |> DateTime.add(3600) |> DateTime.truncate(:second)
assert {:ok, scheduled} = Newsletter.schedule_campaign(campaign, at)
assert scheduled.status == "scheduled"
assert scheduled.scheduled_at == at
end
test "refuses to schedule non-draft campaigns" do
campaign = campaign_fixture()
{:ok, sent} = Newsletter.update_campaign(campaign, %{status: "sent"})
at = DateTime.utc_now() |> DateTime.add(3600) |> DateTime.truncate(:second)
assert {:error, :not_draft} = Newsletter.schedule_campaign(sent, at)
end
end
describe "cancel_campaign/1" do
test "cancels a scheduled campaign" do
campaign = campaign_fixture()
at = DateTime.utc_now() |> DateTime.add(3600) |> DateTime.truncate(:second)
{:ok, scheduled} = Newsletter.schedule_campaign(campaign, at)
assert {:ok, cancelled} = Newsletter.cancel_campaign(scheduled)
assert cancelled.status == "cancelled"
end
test "refuses to cancel non-scheduled campaigns" do
campaign = campaign_fixture()
assert {:error, :not_scheduled} = Newsletter.cancel_campaign(campaign)
end
end
describe "preview_campaign/1" do
test "replaces unsubscribe placeholder" do
campaign = campaign_fixture(body: "Hello! Unsub: {{unsubscribe_url}}")
preview = Newsletter.preview_campaign(campaign)
assert preview =~ "/unsubscribe/sample-token"
refute preview =~ "{{unsubscribe_url}}"
end
end
# ── Settings ─────────────────────────────────────────────────────
describe "newsletter_enabled?/0" do
test "defaults to false" do
refute Newsletter.newsletter_enabled?()
end
test "returns true when enabled" do
Newsletter.set_newsletter_enabled(true)
assert Newsletter.newsletter_enabled?()
end
end
end

View File

@ -0,0 +1,96 @@
defmodule BerrypodWeb.NewsletterControllerTest do
use BerrypodWeb.ConnCase, async: true
alias Berrypod.Newsletter
alias Berrypod.Newsletter.Subscriber
alias Berrypod.Repo
describe "POST /newsletter/subscribe" do
test "subscribes and redirects back", %{conn: conn} do
Newsletter.set_newsletter_enabled(true)
conn =
conn
|> put_req_header("referer", "http://localhost:4002/")
|> post(~p"/newsletter/subscribe", %{email: "new@example.com"})
assert redirected_to(conn) == "/"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Check your inbox"
end
test "handles already confirmed subscriber", %{conn: conn} do
Newsletter.set_newsletter_enabled(true)
# Create a confirmed subscriber directly
now = DateTime.utc_now() |> DateTime.truncate(:second)
%Subscriber{}
|> Subscriber.changeset(%{
email: "confirmed@example.com",
status: "confirmed",
confirmed_at: now
})
|> Repo.insert!()
conn =
conn
|> put_req_header("referer", "http://localhost:4002/about")
|> post(~p"/newsletter/subscribe", %{email: "confirmed@example.com"})
assert redirected_to(conn) == "/about"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "already subscribed"
end
test "shows error for invalid email", %{conn: conn} do
Newsletter.set_newsletter_enabled(true)
conn =
conn
|> put_req_header("referer", "http://localhost:4002/")
|> post(~p"/newsletter/subscribe", %{email: "not-an-email"})
assert redirected_to(conn) == "/"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "valid email"
end
test "redirects to / when no referer", %{conn: conn} do
Newsletter.set_newsletter_enabled(true)
conn = post(conn, ~p"/newsletter/subscribe", %{email: "test@example.com"})
assert redirected_to(conn) == "/"
end
end
describe "GET /newsletter/confirm/:token" do
test "confirms pending subscriber", %{conn: conn} do
# Create a pending subscriber with a known raw token
raw_token = "test-confirmation-token-abc123"
hashed_token = :crypto.hash(:sha256, raw_token) |> Base.encode16(case: :lower)
%Subscriber{}
|> Subscriber.changeset(%{
email: "pending@example.com",
status: "pending",
confirmation_token: hashed_token
})
|> Repo.insert!()
conn = get(conn, "/newsletter/confirm/#{raw_token}")
assert conn.status == 200
assert conn.resp_body =~ "subscribed"
# Verify the subscriber is now confirmed
sub = Repo.get_by!(Subscriber, email: "pending@example.com")
assert sub.status == "confirmed"
end
test "returns error for invalid token", %{conn: conn} do
conn = get(conn, "/newsletter/confirm/invalid-token-here")
assert conn.status == 400
assert conn.resp_body =~ "invalid"
end
end
end

View File

@ -0,0 +1,131 @@
defmodule BerrypodWeb.Admin.NewsletterTest do
use BerrypodWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Berrypod.AccountsFixtures
import Berrypod.NewsletterFixtures
alias Berrypod.Newsletter
setup %{conn: conn} do
user = user_fixture()
conn = log_in_user(conn, user)
%{conn: conn, user: user}
end
describe "overview tab" do
test "shows newsletter toggle", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/newsletter")
assert has_element?(view, "[role=switch]")
end
test "toggles newsletter enabled", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/newsletter")
refute Newsletter.newsletter_enabled?()
view |> element("[role=switch]") |> render_click()
assert Newsletter.newsletter_enabled?()
end
test "shows subscriber counts", %{conn: conn} do
confirmed_subscriber_fixture(email: "a@example.com")
confirmed_subscriber_fixture(email: "b@example.com")
{:ok, _view, html} = live(conn, ~p"/admin/newsletter")
assert html =~ "2"
assert html =~ "Confirmed"
end
end
describe "subscribers tab" do
test "lists subscribers", %{conn: conn} do
confirmed_subscriber_fixture(email: "listed@example.com")
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=subscribers")
assert has_element?(view, "#subscribers")
assert render(view) =~ "listed@example.com"
end
test "filters by status", %{conn: conn} do
confirmed_subscriber_fixture(email: "confirmed@example.com")
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=subscribers")
# Filter to confirmed only
view |> element("button", "Confirmed") |> render_click()
assert render(view) =~ "confirmed@example.com"
end
test "searches subscribers", %{conn: conn} do
confirmed_subscriber_fixture(email: "findme@example.com")
confirmed_subscriber_fixture(email: "other@example.com")
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=subscribers")
view |> element("form") |> render_change(%{search: "findme"})
html = render(view)
assert html =~ "findme@example.com"
refute html =~ "other@example.com"
end
test "deletes subscriber", %{conn: conn} do
confirmed_subscriber_fixture(email: "deleteme@example.com")
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=subscribers")
assert render(view) =~ "deleteme@example.com"
view |> element("button", "Delete") |> render_click()
refute render(view) =~ "deleteme@example.com"
end
end
describe "campaigns tab" do
test "lists campaigns", %{conn: conn} do
campaign_fixture(subject: "Test campaign")
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=campaigns")
assert render(view) =~ "Test campaign"
end
test "deletes draft campaign", %{conn: conn} do
campaign_fixture(subject: "Delete me")
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=campaigns")
assert render(view) =~ "Delete me"
view |> element("button", "Delete") |> render_click()
refute render(view) =~ "Delete me"
end
test "links to new campaign form", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=campaigns")
assert has_element?(view, "a[href='/admin/newsletter/campaigns/new']")
end
end
describe "GET /admin/newsletter/export" do
test "downloads CSV of subscribers", %{conn: conn} do
confirmed_subscriber_fixture(email: "export@example.com")
conn = get(conn, ~p"/admin/newsletter/export")
assert response_content_type(conn, :csv) =~ "text/csv"
assert get_resp_header(conn, "content-disposition") |> hd() =~ "attachment"
assert conn.resp_body =~ "export@example.com"
assert conn.resp_body =~ "email,status"
end
end
end

View File

@ -0,0 +1,40 @@
defmodule Berrypod.NewsletterFixtures do
@moduledoc """
Test helpers for creating newsletter subscribers and campaigns.
"""
alias Berrypod.Newsletter
def unique_email, do: "user#{System.unique_integer([:positive])}@example.com"
@doc """
Creates a confirmed subscriber directly via Repo, bypassing the
confirmation email flow.
"""
def confirmed_subscriber_fixture(attrs \\ %{}) do
now = DateTime.utc_now() |> DateTime.truncate(:second)
attrs =
Enum.into(attrs, %{
email: unique_email(),
status: "confirmed",
confirmed_at: now,
consent_text: "Test signup"
})
%Newsletter.Subscriber{}
|> Newsletter.Subscriber.changeset(attrs)
|> Berrypod.Repo.insert!()
end
def campaign_fixture(attrs \\ %{}) do
attrs =
Enum.into(attrs, %{
subject: "Test campaign #{System.unique_integer([:positive])}",
body: "Hello! Unsubscribe: {{unsubscribe_url}}"
})
{:ok, campaign} = Newsletter.create_campaign(attrs)
campaign
end
end