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:
+ +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", "#{&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
+
| + + | +
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.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'sHi
") + assert html =~ "<Shop>" + refute html =~ "