diff --git a/lib/berrypod/newsletter/notifier.ex b/lib/berrypod/newsletter/notifier.ex index 675751f..c0fdf9d 100644 --- a/lib/berrypod/newsletter/notifier.ex +++ b/lib/berrypod/newsletter/notifier.ex @@ -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 = """ +

Thanks for signing up to the #{esc(shop_name)} newsletter!

+

Please confirm your email to start receiving updates:

+

+ Confirm subscription +

+

This link expires in 48 hours. If you didn't subscribe, just ignore this email.

+ """ + + 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", "
") + |> then(&"

#{&1}

") + 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 + """ +

+ You're receiving this because you subscribed to the #{esc(shop_name)} newsletter.
+ Unsubscribe +

+ """ + else + "" + end + + """ + + + + + + #{esc(shop_name)} + + + + + + +
+ + + + + + + +
+ #{esc(shop_name)} +
+ #{content} + #{footer} +
+
+ + + """ + 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(#{url}) + 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 diff --git a/test/berrypod/newsletter/notifier_test.exs b/test/berrypod/newsletter/notifier_test.exs new file mode 100644 index 0000000..e8f5f04 --- /dev/null +++ b/test/berrypod/newsletter/notifier_test.exs @@ -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 =~ "" + 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 =~ "" + assert email.html_body =~ "

Hello world!

" + assert email.html_body =~ "

Second paragraph.

" + 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 =~ "

First paragraph.

" + assert html =~ "

Second paragraph.

" + end + + test "preserves single line breaks within paragraphs" do + html = Notifier.text_to_html("Line one.\nLine two.") + assert html =~ "Line one.
Line two." + end + + test "escapes HTML in content" do + html = Notifier.text_to_html("Use bold & \"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(" + end + end + + describe "wrap_html/2" do + test "includes shop name in header" do + html = Notifier.wrap_html("Test Shop", "

Hello

") + assert html =~ "Test Shop" + assert html =~ "" + assert html =~ "

Hello

" + end + + test "includes unsubscribe link when provided" do + html = Notifier.wrap_html("Shop", "

Hi

", "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", "

Hi

") + refute html =~ "Unsubscribe" + end + + test "escapes shop name in HTML" do + html = Notifier.wrap_html("Bob's ", "

Hi

") + assert html =~ "<Shop>" + refute html =~ "" + end + end +end