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
@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
@ -14,9 +17,7 @@ defmodule Berrypod.Newsletter.Notifier do
shop_name = Berrypod.Settings.get_setting("shop_name", "Berrypod")
confirm_url = BerrypodWeb.Endpoint.url() <> "/newsletter/confirm/" <> raw_token
body = """
==============================
text = """
Thanks for signing up to the #{shop_name} newsletter!
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,
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
@doc "Sends a campaign email to a single subscriber."
@ -40,7 +50,7 @@ defmodule Berrypod.Newsletter.Notifier do
body_with_url =
String.replace(campaign.body, "{{unsubscribe_url}}", unsubscribe_url)
full_body = """
text = """
#{body_with_url}
---
@ -48,12 +58,16 @@ defmodule Berrypod.Newsletter.Notifier do
Unsubscribe: #{unsubscribe_url}
"""
html_content = text_to_html(body_with_url, unsubscribe_url)
html = wrap_html(shop_name, html_content, unsubscribe_url)
email =
new()
|> to(subscriber.email)
|> from({shop_name, from_address()})
|> subject(campaign.subject)
|> text_body(full_body)
|> text_body(text)
|> html_body(html)
|> header("List-Unsubscribe", "<#{unsubscribe_url}>")
|> header("List-Unsubscribe-Post", "List-Unsubscribe=One-Click")
@ -67,7 +81,7 @@ defmodule Berrypod.Newsletter.Notifier do
end
end
defp deliver(recipient, subject, body) do
defp deliver(recipient, subject, text, html) do
shop_name = Berrypod.Settings.get_setting("shop_name", "Berrypod")
email =
@ -75,7 +89,8 @@ defmodule Berrypod.Newsletter.Notifier do
|> to(recipient)
|> from({shop_name, from_address()})
|> subject(subject)
|> text_body(body)
|> text_body(text)
|> html_body(html)
case Mailer.deliver(email) do
{:ok, _metadata} = result ->
@ -87,6 +102,89 @@ defmodule Berrypod.Newsletter.Notifier do
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
Berrypod.Settings.get_setting("email_from_address", "noreply@example.com")
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