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