add HTML email templates for newsletter
All checks were successful
deploy / deploy (push) Successful in 1m2s
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:
parent
620f31dde3
commit
199f0b506f
@ -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("&", "&")
|
||||
|> String.replace("<", "<")
|
||||
|> String.replace(">", ">")
|
||||
|> String.replace("\"", """)
|
||||
end
|
||||
|
||||
defp from_address do
|
||||
Berrypod.Settings.get_setting("email_from_address", "noreply@example.com")
|
||||
end
|
||||
|
||||
136
test/berrypod/newsletter/notifier_test.exs
Normal file
136
test/berrypod/newsletter/notifier_test.exs
Normal 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 =~ "<b>bold</b>"
|
||||
assert html =~ "&"
|
||||
assert html =~ ""quotes""
|
||||
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 =~ "<Shop>"
|
||||
refute html =~ "<Shop>"
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue
Block a user