add HTML email templates for newsletter
All checks were successful
deploy / deploy (push) Successful in 1m2s

Multipart emails (HTML + plain text fallback) with a branded wrapper:
shop name header, content area with auto-linked URLs and paragraph
formatting, and unsubscribe footer. Applied to both confirmation and
campaign emails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-28 23:32:27 +00:00
parent 620f31dde3
commit 199f0b506f
2 changed files with 245 additions and 11 deletions

View File

@ -1,6 +1,9 @@
defmodule Berrypod.Newsletter.Notifier do defmodule Berrypod.Newsletter.Notifier do
@moduledoc """ @moduledoc """
Plain text email templates for newsletter confirmation and campaigns. Email delivery for newsletter confirmation and campaigns.
Sends multipart emails (HTML + plain text fallback) wrapped in a
simple branded template with the shop name and unsubscribe footer.
""" """
import Swoosh.Email import Swoosh.Email
@ -14,9 +17,7 @@ defmodule Berrypod.Newsletter.Notifier do
shop_name = Berrypod.Settings.get_setting("shop_name", "Berrypod") shop_name = Berrypod.Settings.get_setting("shop_name", "Berrypod")
confirm_url = BerrypodWeb.Endpoint.url() <> "/newsletter/confirm/" <> raw_token confirm_url = BerrypodWeb.Endpoint.url() <> "/newsletter/confirm/" <> raw_token
body = """ text = """
==============================
Thanks for signing up to the #{shop_name} newsletter! Thanks for signing up to the #{shop_name} newsletter!
Please confirm your email to start receiving updates: Please confirm your email to start receiving updates:
@ -25,11 +26,20 @@ defmodule Berrypod.Newsletter.Notifier do
This link expires in 48 hours. If you didn't subscribe, This link expires in 48 hours. If you didn't subscribe,
just ignore this email and you won't hear from us. just ignore this email and you won't hear from us.
==============================
""" """
deliver(subscriber.email, "Confirm your subscription", body) html_content = """
<p>Thanks for signing up to the #{esc(shop_name)} newsletter!</p>
<p>Please confirm your email to start receiving updates:</p>
<p style="margin: 24px 0;">
<a href="#{esc(confirm_url)}" style="display: inline-block; padding: 12px 24px; background-color: #111827; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600;">Confirm subscription</a>
</p>
<p style="color: #6b7280; font-size: 14px;">This link expires in 48 hours. If you didn't subscribe, just ignore this email.</p>
"""
html = wrap_html(shop_name, html_content)
deliver(subscriber.email, "Confirm your subscription", text, html)
end end
@doc "Sends a campaign email to a single subscriber." @doc "Sends a campaign email to a single subscriber."
@ -40,7 +50,7 @@ defmodule Berrypod.Newsletter.Notifier do
body_with_url = body_with_url =
String.replace(campaign.body, "{{unsubscribe_url}}", unsubscribe_url) String.replace(campaign.body, "{{unsubscribe_url}}", unsubscribe_url)
full_body = """ text = """
#{body_with_url} #{body_with_url}
--- ---
@ -48,12 +58,16 @@ defmodule Berrypod.Newsletter.Notifier do
Unsubscribe: #{unsubscribe_url} Unsubscribe: #{unsubscribe_url}
""" """
html_content = text_to_html(body_with_url, unsubscribe_url)
html = wrap_html(shop_name, html_content, unsubscribe_url)
email = email =
new() new()
|> to(subscriber.email) |> to(subscriber.email)
|> from({shop_name, from_address()}) |> from({shop_name, from_address()})
|> subject(campaign.subject) |> subject(campaign.subject)
|> text_body(full_body) |> text_body(text)
|> html_body(html)
|> header("List-Unsubscribe", "<#{unsubscribe_url}>") |> header("List-Unsubscribe", "<#{unsubscribe_url}>")
|> header("List-Unsubscribe-Post", "List-Unsubscribe=One-Click") |> header("List-Unsubscribe-Post", "List-Unsubscribe=One-Click")
@ -67,7 +81,7 @@ defmodule Berrypod.Newsletter.Notifier do
end end
end end
defp deliver(recipient, subject, body) do defp deliver(recipient, subject, text, html) do
shop_name = Berrypod.Settings.get_setting("shop_name", "Berrypod") shop_name = Berrypod.Settings.get_setting("shop_name", "Berrypod")
email = email =
@ -75,7 +89,8 @@ defmodule Berrypod.Newsletter.Notifier do
|> to(recipient) |> to(recipient)
|> from({shop_name, from_address()}) |> from({shop_name, from_address()})
|> subject(subject) |> subject(subject)
|> text_body(body) |> text_body(text)
|> html_body(html)
case Mailer.deliver(email) do case Mailer.deliver(email) do
{:ok, _metadata} = result -> {:ok, _metadata} = result ->
@ -87,6 +102,89 @@ defmodule Berrypod.Newsletter.Notifier do
end end
end end
# Converts plain text campaign body to HTML paragraphs.
# Splits on blank lines for paragraphs, preserves single line breaks.
@doc false
def text_to_html(text, unsubscribe_url \\ nil) do
text
|> String.replace("{{unsubscribe_url}}", unsubscribe_url || "#")
|> String.split(~r/\n{2,}/, trim: true)
|> Enum.map(fn paragraph ->
paragraph
|> String.trim()
|> esc()
|> linkify()
|> String.replace("\n", "<br>")
|> then(&"<p>#{&1}</p>")
end)
|> Enum.join("\n")
end
# Wraps HTML content in a branded email template.
# Keeps it simple: single column, shop name header, optional unsubscribe footer.
@doc false
def wrap_html(shop_name, content, unsubscribe_url \\ nil) do
footer =
if unsubscribe_url do
"""
<p style="color: #9ca3af; font-size: 12px; margin-top: 32px; padding-top: 16px; border-top: 1px solid #e5e7eb;">
You're receiving this because you subscribed to the #{esc(shop_name)} newsletter.<br>
<a href="#{esc(unsubscribe_url)}" style="color: #9ca3af;">Unsubscribe</a>
</p>
"""
else
""
end
"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>#{esc(shop_name)}</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f3f4f6; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
<table role="presentation" width="100%" style="background-color: #f3f4f6;">
<tr>
<td align="center" style="padding: 32px 16px;">
<table role="presentation" width="100%" style="max-width: 560px; background-color: #ffffff; border-radius: 8px; overflow: hidden;">
<tr>
<td style="padding: 24px 32px 16px; border-bottom: 1px solid #e5e7eb;">
<strong style="font-size: 18px; color: #111827;">#{esc(shop_name)}</strong>
</td>
</tr>
<tr>
<td style="padding: 24px 32px 32px; color: #374151; font-size: 16px; line-height: 1.6;">
#{content}
#{footer}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
"""
end
# Turns bare URLs in escaped text into clickable links.
defp linkify(escaped_text) do
Regex.replace(~r/(https?:\/\/[^\s<]+)/, escaped_text, fn url, _ ->
~s(<a href="#{url}" style="color: #2563eb;">#{url}</a>)
end)
end
# HTML-escapes a string for safe embedding.
defp esc(text) do
text
|> String.replace("&", "&amp;")
|> String.replace("<", "&lt;")
|> String.replace(">", "&gt;")
|> String.replace("\"", "&quot;")
end
defp from_address do defp from_address do
Berrypod.Settings.get_setting("email_from_address", "noreply@example.com") Berrypod.Settings.get_setting("email_from_address", "noreply@example.com")
end end

View File

@ -0,0 +1,136 @@
defmodule Berrypod.Newsletter.NotifierTest do
use Berrypod.DataCase, async: true
import Swoosh.TestAssertions
import Berrypod.NewsletterFixtures
alias Berrypod.Newsletter.Notifier
describe "deliver_confirmation/2" do
test "sends multipart email with confirmation link" do
sub = confirmed_subscriber_fixture(email: "confirm@example.com")
token = "test-token-123"
assert {:ok, _} = Notifier.deliver_confirmation(sub, token)
assert_email_sent(fn email ->
assert email.to == [{"", "confirm@example.com"}]
assert email.subject == "Confirm your subscription"
# text fallback
assert email.text_body =~ "confirm@example.com" || email.text_body =~ token
assert email.text_body =~ "/newsletter/confirm/test-token-123"
# html version
assert email.html_body =~ "Confirm subscription"
assert email.html_body =~ "/newsletter/confirm/test-token-123"
assert email.html_body =~ "<!DOCTYPE html>"
end)
end
end
describe "deliver_campaign/2" do
test "sends multipart email with HTML wrapper" do
sub = confirmed_subscriber_fixture(email: "reader@example.com")
campaign = campaign_fixture(subject: "Big news", body: "Hello world!\n\nSecond paragraph.")
assert {:ok, _} = Notifier.deliver_campaign(campaign, sub)
assert_email_sent(fn email ->
assert email.to == [{"", "reader@example.com"}]
assert email.subject == "Big news"
# text fallback has body and unsubscribe
assert email.text_body =~ "Hello world!"
assert email.text_body =~ "Unsubscribe:"
# html version wraps in template
assert email.html_body =~ "<!DOCTYPE html>"
assert email.html_body =~ "<p>Hello world!</p>"
assert email.html_body =~ "<p>Second paragraph.</p>"
assert email.html_body =~ "Unsubscribe"
end)
end
test "replaces {{unsubscribe_url}} in both text and html" do
sub = confirmed_subscriber_fixture(email: "unsub@example.com")
campaign =
campaign_fixture(body: "Click here to unsubscribe: {{unsubscribe_url}}")
assert {:ok, _} = Notifier.deliver_campaign(campaign, sub)
assert_email_sent(fn email ->
refute email.text_body =~ "{{unsubscribe_url}}"
refute email.html_body =~ "{{unsubscribe_url}}"
assert email.text_body =~ "/unsubscribe/"
assert email.html_body =~ "/unsubscribe/"
end)
end
test "includes List-Unsubscribe headers" do
sub = confirmed_subscriber_fixture()
campaign = campaign_fixture()
assert {:ok, _} = Notifier.deliver_campaign(campaign, sub)
assert_email_sent(fn email ->
headers = Map.new(email.headers)
assert headers["List-Unsubscribe"] =~ "/unsubscribe/"
assert headers["List-Unsubscribe-Post"] == "List-Unsubscribe=One-Click"
end)
end
end
describe "text_to_html/1" do
test "wraps paragraphs split by blank lines" do
html = Notifier.text_to_html("First paragraph.\n\nSecond paragraph.")
assert html =~ "<p>First paragraph.</p>"
assert html =~ "<p>Second paragraph.</p>"
end
test "preserves single line breaks within paragraphs" do
html = Notifier.text_to_html("Line one.\nLine two.")
assert html =~ "Line one.<br>Line two."
end
test "escapes HTML in content" do
html = Notifier.text_to_html("Use <b>bold</b> & \"quotes\"")
assert html =~ "&lt;b&gt;bold&lt;/b&gt;"
assert html =~ "&amp;"
assert html =~ "&quot;quotes&quot;"
end
test "turns URLs into links" do
html = Notifier.text_to_html("Visit https://example.com/shop for more")
assert html =~ ~s(<a href="https://example.com/shop")
assert html =~ "https://example.com/shop</a>"
end
end
describe "wrap_html/2" do
test "includes shop name in header" do
html = Notifier.wrap_html("Test Shop", "<p>Hello</p>")
assert html =~ "Test Shop"
assert html =~ "<!DOCTYPE html>"
assert html =~ "<p>Hello</p>"
end
test "includes unsubscribe link when provided" do
html = Notifier.wrap_html("Shop", "<p>Hi</p>", "https://example.com/unsub")
assert html =~ "https://example.com/unsub"
assert html =~ "Unsubscribe"
end
test "omits footer when no unsubscribe url" do
html = Notifier.wrap_html("Shop", "<p>Hi</p>")
refute html =~ "Unsubscribe"
end
test "escapes shop name in HTML" do
html = Notifier.wrap_html("Bob's <Shop>", "<p>Hi</p>")
assert html =~ "&lt;Shop&gt;"
refute html =~ "<Shop>"
end
end
end