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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user