diff --git a/lib/modules/publishing/web/templates/show.html.heex b/lib/modules/publishing/web/templates/show.html.heex
index b76d32cfb..d3cc026dd 100644
--- a/lib/modules/publishing/web/templates/show.html.heex
+++ b/lib/modules/publishing/web/templates/show.html.heex
@@ -48,6 +48,13 @@
size={:sm}
/>
<% end %>
+ <%!-- Admin Edit Button --%>
+ <%= if assigns[:admin_edit_url] do %>
+
+ <% end %>
<%!-- Version History Dropdown --%>
<%= if @version_dropdown do %>
diff --git a/lib/modules/shop/web/catalog_category.ex b/lib/modules/shop/web/catalog_category.ex
index b2b1a3d26..ede66fa2d 100644
--- a/lib/modules/shop/web/catalog_category.ex
+++ b/lib/modules/shop/web/catalog_category.ex
@@ -16,6 +16,7 @@ defmodule PhoenixKit.Modules.Shop.Web.CatalogCategory do
alias PhoenixKit.Modules.Shop.Web.Helpers
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Routes
+ alias PhoenixKitWeb.AdminEditHelper
@impl true
def mount(%{"slug" => slug} = params, _session, socket) do
@@ -104,8 +105,10 @@ defmodule PhoenixKit.Modules.Shop.Web.CatalogCategory do
:category_icon_mode,
Settings.get_setting_cached("shop_category_icon_mode", "none")
)
- |> assign(:admin_edit_url, Routes.path("/admin/shop/categories/#{category.uuid}/edit"))
- |> assign(:admin_edit_label, "Edit Category")
+ |> AdminEditHelper.assign_admin_edit(
+ Routes.path("/admin/shop/categories/#{category.uuid}/edit"),
+ "Edit Category"
+ )
{:ok, socket}
end
diff --git a/lib/modules/shop/web/catalog_product.ex b/lib/modules/shop/web/catalog_product.ex
index 9042ad308..2f2c92b5e 100644
--- a/lib/modules/shop/web/catalog_product.ex
+++ b/lib/modules/shop/web/catalog_product.ex
@@ -23,6 +23,7 @@ defmodule PhoenixKit.Modules.Shop.Web.CatalogProduct do
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Date, as: UtilsDate
alias PhoenixKit.Utils.Routes
+ alias PhoenixKitWeb.AdminEditHelper
# Data URI placeholder for broken images - works without external file serving
@placeholder_data_uri "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'%3E%3Crect width='400' height='400' fill='%23e5e7eb'/%3E%3Cg fill='%239ca3af'%3E%3Crect x='160' y='140' width='80' height='60' rx='4'/%3E%3Ccircle cx='180' cy='160' r='8'/%3E%3Cpath d='M160 190 l25-20 l15 15 l20-25 l20 30 v10 h-80 z'/%3E%3C/g%3E%3C/svg%3E"
@@ -134,8 +135,10 @@ defmodule PhoenixKit.Modules.Shop.Web.CatalogProduct do
:category_icon_mode,
Settings.get_setting_cached("shop_category_icon_mode", "none")
)
- |> assign(:admin_edit_url, Routes.path("/admin/shop/products/#{product.uuid}/edit"))
- |> assign(:admin_edit_label, "Edit Product")
+ |> AdminEditHelper.assign_admin_edit(
+ Routes.path("/admin/shop/products/#{product.uuid}/edit"),
+ "Edit Product"
+ )
{:ok, socket}
end
@@ -267,8 +270,10 @@ defmodule PhoenixKit.Modules.Shop.Web.CatalogProduct do
:category_icon_mode,
Settings.get_setting_cached("shop_category_icon_mode", "none")
)
- |> assign(:admin_edit_url, Routes.path("/admin/shop/products/#{product.uuid}/edit"))
- |> assign(:admin_edit_label, "Edit Product")
+ |> AdminEditHelper.assign_admin_edit(
+ Routes.path("/admin/shop/products/#{product.uuid}/edit"),
+ "Edit Product"
+ )
{:ok, socket}
end
diff --git a/lib/modules/sitemap/sources/publishing.ex b/lib/modules/sitemap/sources/publishing.ex
index afa5f6b20..7f6a8fd32 100644
--- a/lib/modules/sitemap/sources/publishing.ex
+++ b/lib/modules/sitemap/sources/publishing.ex
@@ -158,7 +158,7 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Publishing do
UrlEntry.new(%{
loc: url,
- lastmod: nil,
+ lastmod: latest_post_date(slug, language),
changefreq: "daily",
priority: 0.7,
title: name,
@@ -384,6 +384,20 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Publishing do
end
end
+ # Latest lastmod among published posts in a group (for group listing pages)
+ defp latest_post_date(group_slug, language) do
+ post_language = language || get_default_language()
+
+ Publishing.list_posts(group_slug, post_language)
+ |> Enum.filter(&published?/1)
+ |> Enum.reject(&excluded?/1)
+ |> Enum.map(&get_post_lastmod/1)
+ |> Enum.reject(&is_nil/1)
+ |> Enum.max(Date, fn -> nil end)
+ rescue
+ _ -> nil
+ end
+
defp get_post_lastmod(post) do
case post do
# Check metadata fields first (PhoenixKit Publishing uses published_at)
diff --git a/lib/modules/sitemap/sources/static.ex b/lib/modules/sitemap/sources/static.ex
index 636032418..45408c3fa 100644
--- a/lib/modules/sitemap/sources/static.ex
+++ b/lib/modules/sitemap/sources/static.ex
@@ -199,7 +199,7 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Static do
UrlEntry.new(%{
loc: url,
- lastmod: Date.utc_today(),
+ lastmod: static_lastmod(path),
changefreq: Map.get(config, "changefreq", "weekly"),
priority: Map.get(config, "priority", 0.5),
title: Map.get(config, "title", path),
@@ -224,7 +224,7 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Static do
UrlEntry.new(%{
loc: url,
- lastmod: Date.utc_today(),
+ lastmod: static_lastmod(path),
changefreq: Map.get(config, "changefreq", "weekly"),
priority: Map.get(config, "priority", 0.5),
title: Map.get(config, "title", path),
@@ -237,6 +237,25 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Static do
end
end
+ # For homepage, use the latest published content date across all publishing groups.
+ # For other static pages, use today's date as a reasonable approximation.
+ defp static_lastmod("/") do
+ alias PhoenixKit.Modules.Sitemap.Sources.Publishing
+
+ if Code.ensure_loaded?(Publishing) and function_exported?(Publishing, :collect, 1) do
+ Publishing.collect([])
+ |> Enum.map(& &1.lastmod)
+ |> Enum.reject(&is_nil/1)
+ |> Enum.max(Date, fn -> Date.utc_today() end)
+ else
+ Date.utc_today()
+ end
+ rescue
+ _ -> Date.utc_today()
+ end
+
+ defp static_lastmod(_path), do: Date.utc_today()
+
# Resolve path from config: explicit path OR via RouteResolver
defp resolve_path(%{"path" => path}) when is_binary(path) and path != "" do
path
diff --git a/lib/phoenix_kit/email/default_provider.ex b/lib/phoenix_kit/email/default_provider.ex
new file mode 100644
index 000000000..2b2497e90
--- /dev/null
+++ b/lib/phoenix_kit/email/default_provider.ex
@@ -0,0 +1,54 @@
+defmodule PhoenixKit.Email.DefaultProvider do
+ @moduledoc """
+ No-op email provider. Used when phoenix_kit_emails package is not installed.
+
+ - Interception: passes emails through unchanged, no tracking
+ - Templates: returns nil ā triggers hardcoded fallbacks in Mailer
+ - AWS: returns empty/false ā Mailer uses static config only
+ """
+ @behaviour PhoenixKit.Email.Provider
+
+ # Interception ā pass through, no tracking
+ @impl true
+ def intercept_before_send(email, _opts), do: email
+ @impl true
+ def handle_after_send(_email, _result), do: :ok
+
+ # Templates ā nil triggers hardcoded fallback
+ @impl true
+ def get_active_template_by_name(_name), do: nil
+ @impl true
+ def render_template(_t, _v), do: %{subject: "", html_body: "", text_body: ""}
+ @impl true
+ def render_template(_t, _v, _l), do: %{subject: "", html_body: "", text_body: ""}
+ @impl true
+ def track_usage(_template), do: :ok
+ @impl true
+ def get_source_module(_template), do: nil
+
+ # AWS ā not configured without package
+ @impl true
+ def get_aws_region, do: ""
+ @impl true
+ def get_aws_access_key, do: ""
+ @impl true
+ def get_aws_secret_key, do: ""
+ @impl true
+ def aws_configured?, do: false
+
+ # Test email ā not supported without emails package
+ @impl true
+ def send_test_tracking_email(_recipient_email, _user_uuid), do: {:error, :not_supported}
+
+ # Provider detection ā basic mapping
+ @impl true
+ def adapter_to_provider_name(nil, default), do: default
+ def adapter_to_provider_name(Swoosh.Adapters.AmazonSES, _), do: "amazon_ses"
+ def adapter_to_provider_name(Swoosh.Adapters.Mailgun, _), do: "mailgun"
+ def adapter_to_provider_name(Swoosh.Adapters.Sendgrid, _), do: "sendgrid"
+ def adapter_to_provider_name(Swoosh.Adapters.SMTP, _), do: "smtp"
+ def adapter_to_provider_name(Swoosh.Adapters.Postmark, _), do: "postmark"
+ def adapter_to_provider_name(Swoosh.Adapters.Local, _), do: "local"
+ def adapter_to_provider_name(Swoosh.Adapters.Test, _), do: "test"
+ def adapter_to_provider_name(_adapter, default), do: default
+end
diff --git a/lib/phoenix_kit/email/provider.ex b/lib/phoenix_kit/email/provider.ex
new file mode 100644
index 000000000..08163f6f8
--- /dev/null
+++ b/lib/phoenix_kit/email/provider.ex
@@ -0,0 +1,33 @@
+defmodule PhoenixKit.Email.Provider do
+ @moduledoc """
+ Unified email provider behaviour.
+
+ Covers interception (pre/post send hooks), DB templates, AWS config,
+ and provider detection. The emails package implements this fully.
+ The DefaultProvider is a no-op that passes emails through unchanged.
+ """
+
+ # Interception
+ @callback intercept_before_send(Swoosh.Email.t(), keyword()) :: Swoosh.Email.t()
+ @callback handle_after_send(Swoosh.Email.t(), {:ok, any()} | {:error, any()}) :: :ok
+
+ # Templates
+ @callback get_active_template_by_name(String.t()) :: map() | nil
+ @callback render_template(map(), map()) :: map()
+ @callback render_template(map(), map(), String.t()) :: map()
+ @callback track_usage(map()) :: :ok
+ @callback get_source_module(map()) :: String.t() | nil
+
+ # AWS config
+ @callback get_aws_region() :: String.t()
+ @callback get_aws_access_key() :: String.t()
+ @callback get_aws_secret_key() :: String.t()
+ @callback aws_configured?() :: boolean()
+
+ # Provider detection
+ @callback adapter_to_provider_name(atom(), String.t()) :: String.t()
+
+ # Test email (only supported by emails package)
+ @callback send_test_tracking_email(String.t(), String.t() | nil) ::
+ {:ok, Swoosh.Email.t()} | {:error, any()}
+end
diff --git a/lib/phoenix_kit/mailer.ex b/lib/phoenix_kit/mailer.ex
index ffde1ecae..88de80341 100644
--- a/lib/phoenix_kit/mailer.ex
+++ b/lib/phoenix_kit/mailer.ex
@@ -24,17 +24,14 @@ defmodule PhoenixKit.Mailer do
import Swoosh.Email
- alias PhoenixKit.Modules.Emails
- alias PhoenixKit.Modules.Emails.Interceptor
- alias PhoenixKit.Modules.Emails.Template
- alias PhoenixKit.Modules.Emails.Templates
- alias PhoenixKit.Modules.Emails.Utils, as: EmailUtils
alias PhoenixKit.Users.Auth.User
- alias PhoenixKit.Utils.Date, as: UtilsDate
- alias PhoenixKit.Utils.Routes
require Logger
+ defp email_provider do
+ Application.get_env(:phoenix_kit, :email_provider, PhoenixKit.Email.DefaultProvider)
+ end
+
@doc """
Gets the mailer module to use for sending emails.
@@ -114,7 +111,7 @@ defmodule PhoenixKit.Mailer do
def send_from_template(template_name, recipient, variables \\ %{}, opts \\ [])
when is_binary(template_name) do
# Get the template from database
- case Templates.get_active_template_by_name(template_name) do
+ case email_provider().get_active_template_by_name(template_name) do
nil ->
{:error, :template_not_found}
@@ -123,7 +120,7 @@ defmodule PhoenixKit.Mailer do
if template.status == "active" do
# Render template with variables in the requested locale
locale = Keyword.get(opts, :locale, "en")
- rendered = Templates.render_template(template, variables, locale)
+ rendered = email_provider().render_template(template, variables, locale)
# Build email
email =
@@ -143,10 +140,10 @@ defmodule PhoenixKit.Mailer do
end
# Track template usage
- Templates.track_usage(template)
+ email_provider().track_usage(template)
# Extract source_module from template metadata
- source_module = Template.get_source_module(template)
+ source_module = email_provider().get_source_module(template)
# Prepare delivery options with category and source_module from template
delivery_opts =
@@ -177,7 +174,7 @@ defmodule PhoenixKit.Mailer do
"""
def deliver_email(email, opts \\ []) do
# Intercept email for tracking before sending
- tracked_email = Interceptor.intercept_before_send(email, opts)
+ tracked_email = email_provider().intercept_before_send(email, opts)
mailer = get_mailer()
@@ -200,7 +197,7 @@ defmodule PhoenixKit.Mailer do
end
# Handle post-send tracking updates
- handle_delivery_result(tracked_email, result, opts)
+ email_provider().handle_after_send(tracked_email, result)
result
end
@@ -219,10 +216,14 @@ defmodule PhoenixKit.Mailer do
# If using AWS SES, override with runtime settings from DB
runtime_config =
if config[:adapter] == Swoosh.Adapters.AmazonSES do
- config
- |> Keyword.put(:region, Emails.get_aws_region())
- |> Keyword.put(:access_key, Emails.get_aws_access_key())
- |> Keyword.put(:secret, Emails.get_aws_secret_key())
+ if email_provider().aws_configured?() do
+ config
+ |> Keyword.put(:region, email_provider().get_aws_region())
+ |> Keyword.put(:access_key, email_provider().get_aws_access_key())
+ |> Keyword.put(:secret, email_provider().get_aws_secret_key())
+ else
+ config
+ end
else
config
end
@@ -251,18 +252,18 @@ defmodule PhoenixKit.Mailer do
# Try to get template from database, fallback to hardcoded
{subject, html_body, text_body} =
- case Templates.get_active_template_by_name("magic_link") do
+ case email_provider().get_active_template_by_name("magic_link") do
nil ->
- # Fallback to hardcoded templates
+ # Fallback to text-only
{
"Your secure login link",
- magic_link_html_body(user, magic_link_url),
+ nil,
magic_link_text_body(user, magic_link_url)
}
template ->
# Use database template with variable substitution
- rendered = Templates.render_template(template, template_variables)
+ rendered = email_provider().render_template(template, template_variables)
{rendered.subject, rendered.html_body, rendered.text_body}
end
@@ -275,10 +276,10 @@ defmodule PhoenixKit.Mailer do
|> text_body(text_body)
# Track template usage if using database template
- case Templates.get_active_template_by_name("magic_link") do
+ case email_provider().get_active_template_by_name("magic_link") do
# No template to track
nil -> :ok
- template -> Templates.track_usage(template)
+ template -> email_provider().track_usage(template)
end
deliver_email(email,
@@ -291,121 +292,14 @@ defmodule PhoenixKit.Mailer do
)
end
- # HTML version of the magic link email
- defp magic_link_html_body(%User{} = user, magic_link_url) do
- """
-
-
-
-
-
-
Your Secure Login Link
-
-
-
-
-
-
-
Hi #{user.email},
-
-
Click the button below to securely log in to your account:
-
-
- Log In Securely
-
-
-
- ā ļø Important: This link will expire in 15 minutes and can only be used once.
-
-
-
If you didn't request this login link, you can safely ignore this email.
-
-
For your security, never share this link with anyone.
-
-
-
-
-
-
- """
- end
-
# Text version of the magic link email
- defp magic_link_text_body(%User{} = user, magic_link_url) do
+ defp magic_link_text_body(_user, magic_link_url) do
"""
- Secure Login Link
-
- Hi #{user.email},
-
- Click the link below to securely log in to your account:
-
- #{magic_link_url}
-
- ā ļø Important: This link will expire in 15 minutes and can only be used once.
-
- If you didn't request this login link, you can safely ignore this email.
-
- For your security, never share this link with anyone.
+ Your login link: #{magic_link_url}
+ This link expires in 15 minutes.
"""
end
- # Handle delivery result for email tracking updates
- defp handle_delivery_result(email, result, opts) do
- # Only process if email tracking is enabled
- if Emails.enabled?() do
- case extract_log_uuid_from_email(email) do
- nil ->
- # No log UUID found, skip tracking
- :ok
-
- log_uuid ->
- case Emails.get_log(log_uuid) do
- nil -> :ok
- log -> update_log_after_delivery(log, result, opts)
- end
- end
- end
- rescue
- # Don't fail email delivery if tracking update fails
- error ->
- Logger.error("Failed to update email tracking after delivery: #{inspect(error)}")
- :ok
- end
-
- # Extract log UUID from email headers
- defp extract_log_uuid_from_email(email) do
- case get_in(email.headers, ["X-PhoenixKit-Log-Id"]) do
- nil -> nil
- log_uuid when is_binary(log_uuid) -> log_uuid
- end
- end
-
- # Update email log based on delivery result
- defp update_log_after_delivery(log, {:ok, response}, _opts) do
- Interceptor.update_after_send(log, response)
- end
-
- defp update_log_after_delivery(log, {:error, error}, _opts) do
- Interceptor.update_after_failure(log, error)
- end
-
- defp update_log_after_delivery(_log, _result, _opts) do
- # Unknown result format, skip update
- :ok
- end
-
# Detect current email provider from configuration
defp detect_provider do
mailer = get_mailer()
@@ -421,220 +315,18 @@ defmodule PhoenixKit.Mailer do
defp detect_builtin_provider do
config = PhoenixKit.Config.get(PhoenixKit.Mailer, [])
adapter = Keyword.get(config, :adapter)
- EmailUtils.adapter_to_provider_name(adapter, "phoenix_kit_builtin")
+ email_provider().adapter_to_provider_name(adapter, "phoenix_kit_builtin")
end
# Detect provider for parent application mailer
defp detect_parent_app_provider(mailer) when is_atom(mailer) do
config = PhoenixKit.Config.get_parent_app_config(mailer, [])
adapter = Keyword.get(config, :adapter)
- EmailUtils.adapter_to_provider_name(adapter, "parent_app_mailer")
+ email_provider().adapter_to_provider_name(adapter, "parent_app_mailer")
end
defp detect_parent_app_provider(_mailer), do: "unknown"
- @doc """
- Send a test tracking email to verify email delivery and tracking functionality.
-
- Uses the 'test_email' template from the database if available,
- falls back to hardcoded template if not found.
-
- This function sends a test email with test links
- to verify that the email tracking system is working correctly.
-
- ## Parameters
-
- - `recipient_email` - The email address to send the test email to
- - `user_uuid` - Optional user UUID to associate with the test email (default: nil)
-
- ## Returns
-
- - `{:ok, %Swoosh.Email{}}` - Email sent successfully
- - `{:error, reason}` - Email failed to send
-
- ## Examples
-
- iex> PhoenixKit.Mailer.send_test_tracking_email("admin@example.com")
- {:ok, %Swoosh.Email{}}
-
- iex> PhoenixKit.Mailer.send_test_tracking_email("admin@example.com", "019...")
- {:ok, %Swoosh.Email{}}
-
- """
- def send_test_tracking_email(recipient_email, user_uuid \\ nil)
- when is_binary(recipient_email) do
- timestamp = UtilsDate.utc_now() |> DateTime.to_string()
- test_link_url = Routes.url("/admin/emails")
-
- # Variables for template substitution
- template_variables = %{
- "recipient_email" => recipient_email,
- "timestamp" => timestamp,
- "test_link_url" => test_link_url
- }
-
- # Try to get template from database, fallback to hardcoded
- {subject, html_body, text_body} =
- case Templates.get_active_template_by_name("test_email") do
- nil ->
- # Fallback to hardcoded templates
- {
- "Test Tracking Email - #{timestamp}",
- test_email_html_body(recipient_email, timestamp),
- test_email_text_body(recipient_email, timestamp)
- }
-
- template ->
- # Use database template with variable substitution
- rendered = Templates.render_template(template, template_variables)
- {rendered.subject, rendered.html_body, rendered.text_body}
- end
-
- email =
- new()
- |> to(recipient_email)
- |> from({get_from_name(), get_from_email()})
- |> subject(subject)
- |> html_body(html_body)
- |> text_body(text_body)
-
- # Track template usage if using database template
- case Templates.get_active_template_by_name("test_email") do
- # No template to track
- nil -> :ok
- template -> Templates.track_usage(template)
- end
-
- deliver_email(email,
- user_uuid: user_uuid,
- template_name: "test_email",
- campaign_id: "test",
- category: "system",
- source_module: "admin",
- provider: detect_provider()
- )
- end
-
- # HTML version of the test tracking email
- defp test_email_html_body(recipient_email, timestamp) do
- test_link_url = Routes.url("/admin/emails")
-
- """
-
-
-
-
-
-
Test Tracking Email
-
-
-
-
-
-
-
-
- ā
Success! This test email was sent successfully through the PhoenixKit email tracking system.
-
-
-
Hello,
-
-
This is a test email to verify that your email tracking system is working correctly. If you received this email, it means:
-
-
- - ā
Email delivery is working
- - ā
AWS SES configuration is correct (if using SES)
- - ā
Email tracking is enabled and logging
- - ā
Configuration set is properly configured
-
-
-
-
š Tracking Information:
-
- Recipient: #{recipient_email}
- Sent at: #{timestamp}
- Campaign: test
- Template: test_email
-
-
-
-
-
-
Click any of the buttons above to test link tracking. Then check your emails in the admin panel to see the tracking data.
-
-
-
-
-
-
-
- """
- end
-
- # Text version of the test tracking email
- defp test_email_text_body(recipient_email, timestamp) do
- test_link_url = Routes.url("/admin/emails")
-
- """
- TEST TRACKING EMAIL - EMAIL SYSTEM VERIFICATION
-
- Success! This test email was sent successfully through the PhoenixKit email tracking system.
-
- Hello,
-
- This is a test email to verify that your email tracking system is working correctly. If you received this email, it means:
-
- ā
Email delivery is working
- ā
AWS SES configuration is correct (if using SES)
- ā
Email tracking is enabled and logging
- ā
Configuration set is properly configured
-
- TRACKING INFORMATION:
- ---------------------
- Recipient: #{recipient_email}
- Sent at: #{timestamp}
- Campaign: test
- Template: test_email
-
- TEST LINKS:
- -----------
- Test these tracking features by visiting:
-
- Test Link 1: #{test_link_url}?test=link1
- Test Link 2: #{test_link_url}?test=link2
- Test Link 3: #{test_link_url}?test=link3
-
- Click any of the links above to test link tracking. Then check your emails in the admin panel to see the tracking data.
-
- ---
- This is an automated test email from PhoenixKit Email Tracking System.
- Check your admin panel at: #{test_link_url}
- """
- end
-
# Get the from email address from configuration or use a default
# Priority: Settings Database > Config file > Default
defp get_from_email do
diff --git a/lib/phoenix_kit/migrations/postgres/v15.ex b/lib/phoenix_kit/migrations/postgres/v15.ex
index c112b7ed0..5ff6c8df2 100644
--- a/lib/phoenix_kit/migrations/postgres/v15.ex
+++ b/lib/phoenix_kit/migrations/postgres/v15.ex
@@ -24,8 +24,6 @@ defmodule PhoenixKit.Migrations.Postgres.V15 do
"""
use Ecto.Migration
- alias Mix.Tasks.PhoenixKit.SeedTemplates
-
@doc """
Run the V15 migration to add email templates system.
"""
@@ -130,10 +128,13 @@ defmodule PhoenixKit.Migrations.Postgres.V15 do
# Private function to seed system email templates
defp seed_system_templates do
- case Code.ensure_loaded(SeedTemplates) do
+ seed_mod = Mix.Tasks.PhoenixKit.SeedTemplates
+
+ case Code.ensure_loaded(seed_mod) do
{:module, _} ->
try do
- SeedTemplates.run(["--quiet"])
+ # credo:disable-for-next-line Credo.Check.Refactor.Apply
+ apply(seed_mod, :run, [["--quiet"]])
rescue
error ->
# Log the error but don't fail the migration
diff --git a/lib/phoenix_kit/migrations/postgres/v31.ex b/lib/phoenix_kit/migrations/postgres/v31.ex
index 1ceaff5fa..6c9c63642 100644
--- a/lib/phoenix_kit/migrations/postgres/v31.ex
+++ b/lib/phoenix_kit/migrations/postgres/v31.ex
@@ -44,8 +44,6 @@ defmodule PhoenixKit.Migrations.Postgres.V31 do
"""
use Ecto.Migration
- alias PhoenixKit.Modules.Emails.Templates
-
@doc """
Run the V31 migration to add the billing system.
"""
@@ -768,16 +766,22 @@ defmodule PhoenixKit.Migrations.Postgres.V31 do
# Seed billing email templates (billing_invoice and billing_receipt) if they don't exist
defp seed_billing_invoice_template do
- case Code.ensure_loaded(Templates) do
+ templates_mod = PhoenixKit.Modules.Emails.Templates
+
+ case Code.ensure_loaded(templates_mod) do
{:module, _} ->
try do
# Check if billing templates already exist
- invoice_exists = Templates.get_template_by_name("billing_invoice") != nil
- receipt_exists = Templates.get_template_by_name("billing_receipt") != nil
+ # Module loaded dynamically ā apply/3 required to avoid compile-time warnings
+ # credo:disable-for-next-line Credo.Check.Refactor.Apply
+ invoice_exists = apply(templates_mod, :get_template_by_name, ["billing_invoice"]) != nil
+ # credo:disable-for-next-line Credo.Check.Refactor.Apply
+ receipt_exists = apply(templates_mod, :get_template_by_name, ["billing_receipt"]) != nil
# If any template is missing, run seed to create all missing system templates
unless invoice_exists and receipt_exists do
- Templates.seed_system_templates()
+ # credo:disable-for-next-line Credo.Check.Refactor.Apply
+ apply(templates_mod, :seed_system_templates, [])
end
:ok
diff --git a/lib/phoenix_kit/users/auth/user_notifier.ex b/lib/phoenix_kit/users/auth/user_notifier.ex
index 23974578f..b8f997e90 100644
--- a/lib/phoenix_kit/users/auth/user_notifier.ex
+++ b/lib/phoenix_kit/users/auth/user_notifier.ex
@@ -27,7 +27,10 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do
import Swoosh.Email
alias PhoenixKit.Mailer
- alias PhoenixKit.Modules.Emails.Templates
+
+ defp email_provider do
+ Application.get_env(:phoenix_kit, :email_provider, PhoenixKit.Email.DefaultProvider)
+ end
# Delivers the email using the appropriate mailer.
# Uses the configured parent application mailer if available,
@@ -105,13 +108,10 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do
# Try to get template from database, fallback to hardcoded
{subject, html_body, text_body} =
- case Templates.get_active_template_by_name("register") do
+ case email_provider().get_active_template_by_name("register") do
nil ->
- # Fallback to hardcoded templates
+ # Fallback to text-only
fallback_text = """
-
- ==============================
-
Hi #{user.email},
You can confirm your account by visiting the URL below:
@@ -119,27 +119,21 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do
#{url}
If you didn't create an account with us, please ignore this.
-
- ==============================
"""
- {
- "Confirm your account",
- confirmation_html_body(user.email, url),
- fallback_text
- }
+ {"Confirm your account", nil, fallback_text}
template ->
# Use database template with variable substitution
- rendered = Templates.render_template(template, template_variables)
+ rendered = email_provider().render_template(template, template_variables)
{rendered.subject, rendered.html_body, rendered.text_body}
end
# Track template usage if using database template
- case Templates.get_active_template_by_name("register") do
+ case email_provider().get_active_template_by_name("register") do
# No template to track
nil -> :ok
- template -> Templates.track_usage(template)
+ template -> email_provider().track_usage(template)
end
deliver(user.email, subject, text_body, html_body)
@@ -160,13 +154,10 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do
# Try to get template from database, fallback to hardcoded
{subject, html_body, text_body} =
- case Templates.get_active_template_by_name("reset_password") do
+ case email_provider().get_active_template_by_name("reset_password") do
nil ->
- # Fallback to hardcoded templates
+ # Fallback to text-only
fallback_text = """
-
- ==============================
-
Hi #{user.email},
You can reset your password by visiting the URL below:
@@ -174,27 +165,21 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do
#{url}
If you didn't request this change, please ignore this.
-
- ==============================
"""
- {
- "Reset your password",
- reset_password_html_body(user.email, url),
- fallback_text
- }
+ {"Reset your password", nil, fallback_text}
template ->
# Use database template with variable substitution
- rendered = Templates.render_template(template, template_variables)
+ rendered = email_provider().render_template(template, template_variables)
{rendered.subject, rendered.html_body, rendered.text_body}
end
# Track template usage if using database template
- case Templates.get_active_template_by_name("reset_password") do
+ case email_provider().get_active_template_by_name("reset_password") do
# No template to track
nil -> :ok
- template -> Templates.track_usage(template)
+ template -> email_provider().track_usage(template)
end
deliver(user.email, subject, text_body, html_body)
@@ -215,13 +200,10 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do
# Try to get template from database, fallback to hardcoded
{subject, html_body, text_body} =
- case Templates.get_active_template_by_name("update_email") do
+ case email_provider().get_active_template_by_name("update_email") do
nil ->
- # Fallback to hardcoded templates
+ # Fallback to text-only
fallback_text = """
-
- ==============================
-
Hi #{user.email},
You can change your email by visiting the URL below:
@@ -229,177 +211,26 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do
#{url}
If you didn't request this change, please ignore this.
-
- ==============================
"""
- {
- "Confirm your email change",
- update_email_html_body(user.email, url),
- fallback_text
- }
+ {"Confirm your email change", nil, fallback_text}
template ->
# Use database template with variable substitution
- rendered = Templates.render_template(template, template_variables)
+ rendered = email_provider().render_template(template, template_variables)
{rendered.subject, rendered.html_body, rendered.text_body}
end
# Track template usage if using database template
- case Templates.get_active_template_by_name("update_email") do
+ case email_provider().get_active_template_by_name("update_email") do
# No template to track
nil -> :ok
- template -> Templates.track_usage(template)
+ template -> email_provider().track_usage(template)
end
deliver(user.email, subject, text_body, html_body)
end
- # HTML template for account confirmation email
- defp confirmation_html_body(email, url) do
- """
-
-
-
-
-
-
Confirm Your Account
-
-
-
-
-
-
-
Hi #{email},
-
-
Thank you for creating an account! To complete your registration, please confirm your email address by clicking the button below:
-
-
- Confirm My Account
-
-
-
- ā¹ļø Note: This confirmation link is secure and will verify your email address.
-
-
-
If you didn't create an account with us, you can safely ignore this email.
-
-
-
-
-
- """
- end
-
- # HTML template for password reset email
- defp reset_password_html_body(email, url) do
- """
-
-
-
-
-
-
Reset Your Password
-
-
-
-
-
-
-
Hi #{email},
-
-
We received a request to reset your password. Click the button below to create a new password:
-
-
- Reset My Password
-
-
-
- ā ļø Security Notice: This password reset link will expire soon for your security.
-
-
-
If you didn't request this password reset, you can safely ignore this email. Your password will remain unchanged.
-
-
-
-
-
- """
- end
-
- # HTML template for email update confirmation
- defp update_email_html_body(email, url) do
- """
-
-
-
-
-
-
Confirm Email Change
-
-
-
-
-
-
-
Hi #{email},
-
-
We received a request to change your email address. To complete this change, please confirm your new email address by clicking the button below:
-
-
- Confirm Email Change
-
-
-
- ā Verification Required: This step ensures your new email address is valid and accessible.
-
-
-
If you didn't request this email change, you can safely ignore this message. Your current email address will remain unchanged.
-
-
-
-
-
- """
- end
-
@doc """
Deliver magic link registration instructions.
@@ -422,13 +253,10 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do
# Try to get template from database, fallback to hardcoded
{subject, html_body, text_body} =
- case Templates.get_active_template_by_name("magic_link_registration") do
+ case email_provider().get_active_template_by_name("magic_link_registration") do
nil ->
- # Fallback to hardcoded templates
+ # Fallback to text-only
fallback_text = """
-
- ==============================
-
Hi #{email},
Welcome! To complete your registration, please click the link below:
@@ -438,78 +266,23 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do
This link will expire in 30 minutes for your security.
If you didn't request this registration, please ignore this email.
-
- ==============================
"""
- {
- "Complete Your Registration",
- magic_link_registration_html_body(email, url),
- fallback_text
- }
+ {"Complete Your Registration", nil, fallback_text}
template ->
# Use database template with variable substitution
- rendered = Templates.render_template(template, template_variables)
+ rendered = email_provider().render_template(template, template_variables)
{rendered.subject, rendered.html_body, rendered.text_body}
end
# Track template usage if using database template
- case Templates.get_active_template_by_name("magic_link_registration") do
+ case email_provider().get_active_template_by_name("magic_link_registration") do
# No template to track
nil -> :ok
- template -> Templates.track_usage(template)
+ template -> email_provider().track_usage(template)
end
deliver(email, subject, text_body, html_body)
end
-
- # HTML template for magic link registration email
- defp magic_link_registration_html_body(email, url) do
- """
-
-
-
-
-
-
Complete Your Registration
-
-
-
-
-
-
-
Hi #{email},
-
-
Thank you for starting your registration! Click the button below to complete your account setup:
-
-
- Complete Registration
-
-
-
- ā¹ļø Security Note: This registration link will expire in 30 minutes and can only be used once.
-
-
-
If you didn't request this registration, you can safely ignore this email.
-
-
-
-
-
- """
- end
end
diff --git a/lib/phoenix_kit_web/components/core/cookie_consent.ex b/lib/phoenix_kit_web/components/core/cookie_consent.ex
index ed04a93b4..2ae48bbf0 100644
--- a/lib/phoenix_kit_web/components/core/cookie_consent.ex
+++ b/lib/phoenix_kit_web/components/core/cookie_consent.ex
@@ -75,6 +75,13 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do
attr :policy_version, :string, default: "1.0", doc: "Policy version for consent tracking"
attr :cookie_policy_url, :string, default: "/legal/cookie-policy"
attr :privacy_policy_url, :string, default: "/legal/privacy-policy"
+
+ attr :legal_links, :list,
+ default: [],
+ doc: "Dynamic list of %{title, url} for published legal pages"
+
+ attr :legal_index_url, :string, default: "/legal", doc: "URL to legal pages index"
+
attr :google_consent_mode, :boolean, default: false, doc: "Enable Google Consent Mode v2"
attr :class, :string, default: ""
@@ -173,7 +180,7 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do
}
.pk-glass {
- background: oklch(var(--b1) / 0.95);
+ background: oklch(var(--b1) / 0.98);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid var(--pk-border);
@@ -190,25 +197,6 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do
transform: translateY(-2px);
box-shadow: 0 4px 12px oklch(var(--bc) / 0.1);
}
-
- .pk-toggle-track {
- background: var(--pk-border);
- transition: background-color 0.2s ease;
- }
-
- .pk-toggle-track.active {
- background: var(--pk-primary);
- }
-
- .pk-toggle-thumb {
- background: var(--pk-bg);
- box-shadow: 0 1px 3px oklch(var(--bc) / 0.2);
- transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
- }
-
- input:checked + .pk-toggle-track .pk-toggle-thumb {
- transform: translateX(20px);
- }
<%!-- Floating Icon (only for opt-in frameworks) --%>
@@ -254,17 +242,16 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do
{gettext("We value your privacy")}
-
+
{gettext(
"We use cookies to enhance your browsing experience and analyze our traffic."
)}
{" "}
- {gettext("Cookie Policy")}
+ {gettext("Legal")}
@@ -308,7 +295,7 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do
>
<%!-- Backdrop --%>
@@ -356,7 +343,7 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do
<%= for category <- @categories do %>