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

@@ -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
use BerrypodWeb, :controller
alias Berrypod.Orders
alias Berrypod.{Newsletter, Orders}
# Unsubscribe links should be long-lived — use 2 years
@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
{:ok, email} ->
Orders.add_suppression(email, "unsubscribed")
Newsletter.unsubscribe(email)
conn
|> put_status(200)
@@ -24,7 +25,7 @@ defmodule BerrypodWeb.UnsubscribeController do
</head>
<body>
<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>
</html>
""")