Current section
Files
Jump to
Current section
Files
lib/phoenix_kit/modules/emails/web/emails.ex
defmodule PhoenixKit.Modules.Emails.Web.Emails do
@moduledoc """
LiveView for displaying and managing emails in PhoenixKit admin panel.
Provides comprehensive email interface with filtering, searching,
and detailed analytics for sent emails.
## Features
- **Real-time Log List**: Live updates of emails
- **Advanced Filtering**: By status, date range, recipient, campaign, template
- **Search Functionality**: Search across recipients, subjects, campaigns
- **Pagination**: Handle large volumes of emails
- **Export**: CSV export functionality
- **Quick Actions**: Resend, view details, mark as reviewed
- **Statistics Summary**: Key metrics at the top of the page
## Route
This LiveView is mounted at `{prefix}/admin/emails` and requires
appropriate admin permissions.
Note: `{prefix}` is your configured PhoenixKit URL prefix (default: `/phoenix_kit`).
The route is auto-generated by PhoenixKit from `admin_tabs/0` at compile time and
injected into `live_session :phoenix_kit_admin`. Do not declare this route in your
parent app's `router.ex`. See `phoenix_kit/guides/custom-admin-pages.md`.
## Permissions
Access is restricted to users with admin or owner roles in PhoenixKit.
"""
use PhoenixKitWeb, :live_view
use Gettext, backend: PhoenixKit.Modules.Emails.Gettext
import PhoenixKitWeb.Components.Core.Icon
import PhoenixKitWeb.Components.Core.TableDefault
import PhoenixKitWeb.Components.Core.DraggableList
import PhoenixKitWeb.Components.Core.TableRowMenu
import PhoenixKitWeb.Components.Core.EmailActivityBadges
import PhoenixKitWeb.Components.Core.Pagination
require Logger
alias PhoenixKit.Modules.Emails
alias PhoenixKit.Modules.Emails.BrevoOnDemandSync
alias PhoenixKit.Modules.Emails.TableColumns
alias PhoenixKit.Users.Auth.Scope
alias PhoenixKit.Utils.Routes
@default_per_page 25
@max_per_page 100
## --- Lifecycle Callbacks ---
@impl true
def mount(_params, _session, socket) do
# Check if email tracking is enabled
if Emails.enabled?() do
# Subscribe to live status updates so the list reflects status changes
# (e.g. sent → delivered/bounced) without a manual page reload.
if connected?(socket) do
case PhoenixKit.Config.pubsub_server() do
nil -> :ok
server -> Phoenix.PubSub.subscribe(server, Emails.email_status_topic())
end
end
# Get project title from settings
# Load table columns configuration
selected_columns = TableColumns.get_user_table_columns()
available_columns = TableColumns.get_available_columns()
socket =
socket
# New PhoenixKit header style: title/subtitle render in the admin shell
# top bar (LayoutWrapper reads these assigns); the in-body
# <.admin_page_header> was removed from emails.html.heex.
|> assign(:page_title, gettext("Emails"))
|> assign(:page_subtitle, gettext("Monitor and track all outgoing emails"))
|> assign(:logs, [])
|> assign(:total_count, 0)
|> assign(:stats, %{})
|> assign(:loading, true)
|> assign(:show_test_email_modal, false)
|> assign(:test_email_sending, false)
|> assign(:test_email_form, %{recipient: "", errors: %{}})
|> assign(:selected_columns, selected_columns)
|> assign(:available_columns, available_columns)
|> assign(:show_column_modal, false)
|> assign(:temp_selected_columns, nil)
|> assign(:sort_by, :sent_at)
|> assign(:sort_dir, :desc)
|> assign_filter_defaults()
|> assign_pagination_defaults()
{:ok, socket}
else
{:ok,
socket
|> put_flash(:error, gettext("Email management is not enabled"))
|> push_navigate(to: Routes.path("/admin"))}
end
end
@impl true
def handle_params(params, _url, socket) do
socket =
socket
|> apply_params(params)
|> load_email_logs()
|> load_stats()
{:noreply, socket}
end
## --- Event Handlers ---
@impl true
def handle_event("filter", params, socket) do
# Handle both search and filter parameters
combined_params = %{}
# Extract search parameters
combined_params =
case Map.get(params, "search") do
%{"query" => query} -> Map.put(combined_params, "search", String.trim(query || ""))
_ -> combined_params
end
# Extract filter parameters
combined_params =
case Map.get(params, "filter") do
filter_params when is_map(filter_params) -> Map.merge(combined_params, filter_params)
_ -> combined_params
end
# Reset to first page when filtering
combined_params = Map.put(combined_params, "page", "1")
# Build new URL parameters
new_params = build_url_params(socket.assigns, combined_params)
{:noreply,
socket
|> push_patch(to: Routes.path("/admin/emails?#{new_params}"))}
end
@impl true
def handle_event("clear_filters", _params, socket) do
{:noreply,
socket
|> push_patch(to: Routes.path("/admin/emails"))}
end
@impl true
def handle_event("clear_search", _params, socket) do
# Clear only the search term (the × inside the search box); keep the other
# filters and reset to page 1.
new_params = build_url_params(socket.assigns, %{"search" => "", "page" => "1"})
{:noreply, push_patch(socket, to: Routes.path("/admin/emails?#{new_params}"))}
end
@impl true
def handle_event("set_filter", %{"field" => field, "value" => value}, socket) do
# Dropdown filter selection (status/category/source_module): set the chosen
# filter, reset to page 1, and patch. Empty value clears that filter.
new_params = build_url_params(socket.assigns, %{field => value, "page" => "1"})
{:noreply, push_patch(socket, to: Routes.path("/admin/emails?#{new_params}"))}
end
@impl true
def handle_event("toggle_sort", %{"by" => by}, socket) do
# Clicking a sortable column header: validate the field, then toggle the
# direction if it's already the active sort, otherwise switch to that field
# ascending. Resets to page 1 and patches.
field = validate_sort_by(by)
sort_dir =
if field == socket.assigns.sort_by do
if socket.assigns.sort_dir == :asc, do: :desc, else: :asc
else
:asc
end
new_params =
build_url_params(socket.assigns, %{
"sort_by" => to_string(field),
"sort_dir" => to_string(sort_dir),
"page" => "1"
})
{:noreply, push_patch(socket, to: Routes.path("/admin/emails?#{new_params}"))}
end
@impl true
def handle_event("view_details", %{"uuid" => log_uuid}, socket) do
{:noreply,
socket
|> push_navigate(to: Routes.path("/admin/emails/email/#{log_uuid}"))}
end
@impl true
def handle_event("sync_log", %{"uuid" => uuid}, socket) do
# Per-email status sync (mirrors details.ex "sync_status"): prefer the AWS
# message ID, fall back to the internal message ID. On success we refresh
# just the affected row in place via update_log_row/2.
case Emails.get_log(uuid) do
nil ->
{:noreply, put_flash(socket, :error, gettext("Email not found"))}
%{provider: "brevo_api"} = log ->
sync_brevo_log(socket, log)
log ->
message_id = log.aws_message_id || log.message_id
if is_nil(message_id) do
{:noreply,
put_flash(socket, :error, gettext("No message ID available to sync this email"))}
else
sync_email_status(socket, uuid, message_id)
end
end
end
@impl true
def handle_event("refresh", _params, socket) do
{:noreply,
socket
|> assign(:loading, true)
|> load_email_logs()
|> load_stats()}
end
@impl true
def handle_event("show_test_email_modal", _params, socket) do
{:noreply,
socket
|> assign(:show_test_email_modal, true)
|> assign(:test_email_form, %{recipient: "", errors: %{}})}
end
@impl true
def handle_event("hide_test_email_modal", _params, socket) do
{:noreply,
socket
|> assign(:show_test_email_modal, false)
|> assign(:test_email_sending, false)
|> assign(:test_email_form, %{recipient: "", errors: %{}})}
end
@impl true
def handle_event("validate_test_email", %{"test_email" => %{"recipient" => recipient}}, socket) do
errors = validate_test_email_form(recipient)
form = %{
recipient: recipient,
errors: errors
}
{:noreply, assign(socket, :test_email_form, form)}
end
@impl true
def handle_event("send_test_email", %{"test_email" => %{"recipient" => recipient}}, socket) do
errors = validate_test_email_form(recipient)
if map_size(errors) == 0 do
# Start sending process
socket = assign(socket, :test_email_sending, true)
# Attribute the test to the current admin (recorded on the email log so
# Details shows the user + source module).
user_uuid =
case socket.assigns[:phoenix_kit_current_scope] do
nil -> nil
scope -> Scope.user_uuid(scope)
end
# Send the test email asynchronously
send(self(), {:send_test_email, String.trim(recipient), user_uuid})
{:noreply, socket}
else
# Show validation errors
form = %{
recipient: recipient,
errors: errors
}
{:noreply, assign(socket, :test_email_form, form)}
end
end
@impl true
def handle_event("show_column_modal", _params, socket) do
# Seed the temporary working copy from the live selection; all edits in the
# modal mutate temp_selected_columns and only persist on Save.
{:noreply,
socket
|> assign(:show_column_modal, true)
|> assign(:temp_selected_columns, socket.assigns.selected_columns)}
end
@impl true
def handle_event("hide_column_modal", _params, socket) do
{:noreply,
socket
|> assign(:show_column_modal, false)
|> assign(:temp_selected_columns, nil)}
end
@impl true
def handle_event("update_table_columns", %{"column_order" => column_order}, socket) do
# Persist the order coming from the hidden input. save_and_close_column_modal
# drops any unknown ids and keeps "actions" last.
columns = String.split(column_order, ",", trim: true)
{:noreply, save_and_close_column_modal(socket, columns)}
end
@impl true
def handle_event("update_table_columns", _params, socket) do
# Fallback: submitted without an order (no reordering happened) — persist
# whatever is currently in the working copy.
columns = socket.assigns.temp_selected_columns || []
{:noreply, save_and_close_column_modal(socket, columns)}
end
@impl true
def handle_event("reorder_selected_columns", %{"ordered_ids" => ids}, socket) do
# Draggable list emits the new order (minus "actions"); re-append it.
{:noreply, assign(socket, :temp_selected_columns, ensure_actions_column(ids))}
end
@impl true
def handle_event("reorder_selected_columns", _params, socket) do
{:noreply, socket}
end
@impl true
def handle_event("add_column", %{"column_id" => column_id}, socket) do
temp = socket.assigns.temp_selected_columns || []
known = known_column_fields(socket.assigns.available_columns)
temp =
if column_id in temp or column_id not in known do
# Already present, or an unknown id from a crafted event — ignore.
temp
else
# Insert before "actions" so it stays the last column.
ensure_actions_column(Enum.reject(temp, &(&1 == "actions")) ++ [column_id])
end
{:noreply, assign(socket, :temp_selected_columns, temp)}
end
@impl true
def handle_event("remove_column", %{"column_id" => column_id}, socket) do
temp = socket.assigns.temp_selected_columns || []
{:noreply, assign(socket, :temp_selected_columns, Enum.reject(temp, &(&1 == column_id)))}
end
@impl true
def handle_event("reset_columns", _params, socket) do
# Reset the working copy to defaults (not persisted until Save).
{:noreply, assign(socket, :temp_selected_columns, TableColumns.reset_columns())}
end
## --- Info Handlers ---
@impl true
def handle_info({:email_log_updated, %{uuid: uuid}}, socket) do
# Only react when the changed log is on the current page. We then update
# just that one row in place (single fetch by uuid) instead of re-running
# the paginated list + count query for every event — during a delivery
# burst that would be a query storm across all connected admins. We also
# deliberately skip load_stats/0: the 30-day aggregate doesn't need to
# recompute per status change.
if Enum.any?(socket.assigns.logs, &(&1.uuid == uuid)) do
{:noreply, update_log_row(socket, uuid)}
else
{:noreply, socket}
end
end
def handle_info({:send_test_email, recipient, user_uuid}, socket) do
provider =
Application.get_env(:phoenix_kit, :email_provider, PhoenixKit.Modules.Emails.Provider)
case provider.send_test_tracking_email(recipient, user_uuid) do
{:ok, _email} ->
Logger.info("Test email sent successfully", %{
recipient: recipient,
module: __MODULE__
})
{:noreply,
socket
|> assign(:test_email_sending, false)
|> assign(:show_test_email_modal, false)
|> put_flash(
:info,
gettext(
"Test email sent successfully to %{recipient}! Check your emails to see the management data.",
recipient: recipient
)
)
|> load_email_logs()
|> load_stats()}
{:error, reason} ->
Logger.error("Failed to send test email", %{
recipient: recipient,
reason: inspect(reason),
module: __MODULE__
})
{:noreply,
socket
|> assign(:test_email_sending, false)
|> put_flash(
:error,
gettext("Failed to send test email: %{reason}", reason: inspect(reason))
)}
end
rescue
error ->
Logger.error("Exception while sending test email", %{
recipient: recipient,
error_message: Exception.message(error),
error_type: error.__struct__,
stacktrace: Exception.format_stacktrace(__STACKTRACE__),
module: __MODULE__
})
{:noreply,
socket
|> assign(:test_email_sending, false)
|> put_flash(
:error,
gettext("Error sending test email: %{message}", message: Exception.message(error))
)}
end
## --- Private Helper Functions ---
# Apply default filter values
defp assign_filter_defaults(socket) do
filters = %{
search: "",
status: "",
message_tag: "",
campaign_id: "",
category: "",
source_module: "",
from_date: "",
to_date: ""
}
assign(socket, :filters, filters)
end
# Apply default pagination values
defp assign_pagination_defaults(socket) do
socket
|> assign(:page, 1)
|> assign(:per_page, @default_per_page)
|> assign(:total_pages, 0)
end
# Apply URL parameters to socket assigns
defp apply_params(socket, params) do
filters = %{
search: params["search"] || "",
status: params["status"] || "",
message_tag: params["message_tag"] || "",
campaign_id: params["campaign_id"] || "",
category: params["category"] || "",
source_module: params["source_module"] || "",
from_date: params["from_date"] || "",
to_date: params["to_date"] || ""
}
page = String.to_integer(params["page"] || "1")
per_page = min(String.to_integer(params["per_page"] || "#{@default_per_page}"), @max_per_page)
sort_by = validate_sort_by(params["sort_by"])
sort_dir = validate_sort_dir(params["sort_dir"])
socket
|> assign(:filters, filters)
|> assign(:page, page)
|> assign(:per_page, per_page)
|> assign(:sort_by, sort_by)
|> assign(:sort_dir, sort_dir)
end
# Load emails based on current filters and pagination
defp load_email_logs(socket) do
%{filters: filters, page: page, per_page: per_page} = socket.assigns
# Build filters for EmailLog query, adding sort order (list only — the count
# query below intentionally omits ordering).
query_filters =
build_query_filters(filters, page, per_page)
|> Map.put(:order_by, socket.assigns.sort_by)
|> Map.put(:order_dir, socket.assigns.sort_dir)
logs = Emails.list_logs(query_filters)
# Get total count for pagination (efficient count without loading all records)
total_count =
Emails.count_logs(build_query_filters(filters, 1, 1) |> Map.drop([:limit, :offset]))
total_pages = ceil(total_count / per_page)
socket
|> assign(:logs, logs)
|> assign(:total_count, total_count)
|> assign(:total_pages, total_pages)
|> assign(:loading, false)
end
# Replace a single displayed log row with its freshly-fetched version.
# Avoids re-running the paginated list/count queries on PubSub status events.
# If the log can no longer be loaded (e.g. deleted), the existing row is kept
# until the next real navigation/refresh.
defp update_log_row(socket, uuid) do
case Emails.get_log(uuid) do
nil ->
socket
log ->
logs =
Enum.map(socket.assigns.logs, fn
%{uuid: ^uuid} -> log
other -> other
end)
assign(socket, :logs, logs)
end
end
# Defensive per-email status sync. Wraps Emails.sync_email_status/1 so an
# unexpected raise surfaces as a flash instead of crashing the LiveView, and
# refreshes just the affected row on success.
defp sync_email_status(socket, uuid, message_id) do
case Emails.sync_email_status(message_id) do
{:ok, _result} ->
socket
|> update_log_row(uuid)
|> put_flash(:info, gettext("Email status updated"))
|> then(&{:noreply, &1})
{:error, _reason} ->
{:noreply, put_flash(socket, :error, gettext("Failed to update email status"))}
end
rescue
error ->
Logger.error("Exception while syncing email status", %{
uuid: uuid,
error_message: Exception.message(error),
module: __MODULE__
})
{:noreply, put_flash(socket, :error, gettext("Failed to update email status"))}
end
# Brevo branch of "sync_log" — see BrevoOnDemandSync's moduledoc.
defp sync_brevo_log(socket, log) do
case BrevoOnDemandSync.sync(log) do
{:ok, %{events_processed: 0}} ->
{:noreply, put_flash(socket, :info, gettext("No new events"))}
{:ok, %{events_processed: count}} ->
{:noreply,
socket
|> update_log_row(log.uuid)
|> put_flash(:info, gettext("Received %{count} new event(s)", count: count))}
{:error, reason} ->
{:noreply, put_flash(socket, :error, reason)}
end
end
# Persist the working column set, refresh the live selection, and close. Drops
# any id not present in @available_columns — so a crafted client event can't
# persist an unknown column the table has no header for — and keeps "actions"
# last. This is the single chokepoint every persist path funnels through.
defp save_and_close_column_modal(socket, columns) do
known = known_column_fields(socket.assigns.available_columns)
columns =
columns
|> Enum.filter(&(&1 in known))
|> ensure_actions_column()
case TableColumns.update_user_table_columns(columns) do
{:ok, _setting} ->
socket
|> assign(:selected_columns, TableColumns.get_user_table_columns())
|> assign(:temp_selected_columns, nil)
|> assign(:show_column_modal, false)
|> put_flash(:info, gettext("Table columns updated successfully"))
{:error, _reason} ->
socket
|> assign(:show_column_modal, false)
|> put_flash(:error, gettext("Failed to update table columns"))
end
end
# Ensure the always-present "actions" column sits at the end exactly once.
defp ensure_actions_column(columns) do
Enum.reject(columns, &(&1 == "actions")) ++ ["actions"]
end
# Field ids that actually exist in the available-columns metadata. Used to drop
# unknown ids a crafted client event might submit before they are persisted.
defp known_column_fields(available_columns) do
Enum.map(available_columns, & &1.field)
end
# Look up a column's display label from @available_columns by its field key,
# falling back to the raw id when unknown.
defp column_label(available_columns, field) do
case Enum.find(available_columns, fn col -> col.field == field end) do
nil -> field
col -> col.label
end
end
# Load summary statistics
defp load_stats(socket) do
stats = Emails.get_system_stats(:last_30_days)
assign(socket, :stats, stats)
end
# Build query filters from form filters
defp build_query_filters(filters, page, per_page) do
query_filters = %{
limit: per_page,
offset: (page - 1) * per_page
}
# Add non-empty filters
query_filters =
filters
|> Enum.reduce(query_filters, fn
{:search, search}, acc when search != "" ->
# Search in recipient, subject, and campaign fields
Map.put(acc, :search, search)
{:status, status}, acc when status != "" ->
Map.put(acc, :status, status)
{:message_tag, message_tag}, acc when message_tag != "" ->
Map.put(acc, :message_tag, message_tag)
{:campaign_id, campaign_id}, acc when campaign_id != "" ->
Map.put(acc, :campaign_id, campaign_id)
{:category, category}, acc when category != "" ->
Map.put(acc, :category, category)
{:source_module, source_module}, acc when source_module != "" ->
Map.put(acc, :source_module, source_module)
{:from_date, from_date}, acc when from_date != "" ->
case Date.from_iso8601(from_date) do
{:ok, date} -> Map.put(acc, :from_date, DateTime.new!(date, ~T[00:00:00]))
_ -> acc
end
{:to_date, to_date}, acc when to_date != "" ->
case Date.from_iso8601(to_date) do
{:ok, date} -> Map.put(acc, :to_date, DateTime.new!(date, ~T[23:59:59]))
_ -> acc
end
_, acc ->
acc
end)
query_filters
end
# Build URL parameters from current state
defp build_url_params(assigns, additional_params) do
base_params = %{
"search" => assigns.filters.search,
"status" => assigns.filters.status,
"message_tag" => assigns.filters.message_tag,
"campaign_id" => assigns.filters.campaign_id,
"category" => assigns.filters.category,
"source_module" => assigns.filters.source_module,
"from_date" => assigns.filters.from_date,
"to_date" => assigns.filters.to_date,
"sort_by" => to_string(assigns.sort_by),
"sort_dir" => to_string(assigns.sort_dir),
"page" => assigns.page,
"per_page" => assigns.per_page
}
Map.merge(base_params, additional_params)
|> Enum.reject(fn {_key, value} -> value == "" or is_nil(value) end)
|> Map.new()
|> URI.encode_query()
end
# Build export URL with current filters
defp build_export_url(filters) do
# Convert filters to query parameters
params =
filters
|> Enum.reject(fn {_key, value} -> value == "" or is_nil(value) end)
|> Enum.into(%{})
|> URI.encode_query()
base_url = Routes.path("/admin/emails/export")
if params != "" do
"#{base_url}?#{params}"
else
base_url
end
end
# Helper functions for template
# Whitelist of fields that may be passed to Emails.list_logs/1 :order_by.
# Anything outside this set falls back to the default sent_at.
@sort_fields [:sent_at, :to, :subject, :status, :template_name]
# Column "field" strings (as stored in selected_columns) whose table header
# is rendered as a clickable <.sort_header_cell>. Maps the string column key
# to its sort atom — guards String.to_existing_atom against unexpected input.
@sortable_columns %{"to" => :to, "subject" => :subject, "status" => :status}
defp sortable_column(field), do: Map.get(@sortable_columns, field)
defp validate_sort_by(value) when is_atom(value) do
if value in @sort_fields, do: value, else: :sent_at
end
defp validate_sort_by(value) when is_binary(value) do
case Enum.find(@sort_fields, fn field -> Atom.to_string(field) == value end) do
nil -> :sent_at
field -> field
end
end
defp validate_sort_by(_), do: :sent_at
defp validate_sort_dir(:asc), do: :asc
defp validate_sort_dir(:desc), do: :desc
defp validate_sort_dir("asc"), do: :asc
defp validate_sort_dir("desc"), do: :desc
defp validate_sort_dir(_), do: :desc
# Validate test email form
defp validate_test_email_form(recipient) do
errors = %{}
# Validate recipient email
errors =
case String.trim(recipient || "") do
"" ->
Map.put(errors, :recipient, "Email address is required")
email ->
if Regex.match?(~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/, email) do
errors
else
Map.put(errors, :recipient, "Please enter a valid email address")
end
end
errors
end
end