Packages

phoenix_kit

1.4.8
1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit_web live modules emails emails.ex
Raw

lib/phoenix_kit_web/live/modules/emails/emails.ex

defmodule PhoenixKitWeb.Live.Modules.Emails.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`).
## Usage
# In your Phoenix router
live "/admin/emails", PhoenixKitWeb.Live.Modules.Emails.Emails, :index
## Permissions
Access is restricted to users with admin or owner roles in PhoenixKit.
"""
use PhoenixKitWeb, :live_view
require Logger
alias PhoenixKit.Emails
alias PhoenixKit.Emails.TableColumns
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Date, as: UtilsDate
alias PhoenixKit.Utils.Routes
import PhoenixKitWeb.Components.Core.Icons, only: [icon_arrow_left: 1]
@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
# Get project title from settings
project_title = Settings.get_setting("project_title", "PhoenixKit")
# Load table columns configuration
selected_columns = TableColumns.get_user_table_columns()
available_columns = TableColumns.get_available_columns()
socket =
socket
|> assign(:page_title, "Emails")
|> assign(:project_title, project_title)
|> 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_filter_defaults()
|> assign_pagination_defaults()
{:ok, socket}
else
{:ok,
socket
|> put_flash(:error, "Email management is not enabled")
|> push_navigate(to: Routes.path("/admin/dashboard"))}
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("view_details", %{"id" => log_id}, socket) do
{:noreply,
socket
|> push_navigate(to: Routes.path("/admin/emails/email/#{log_id}"))}
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)
# Send the test email asynchronously
send(self(), {:send_test_email, String.trim(recipient)})
{: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
{:noreply, assign(socket, :show_column_modal, true)}
end
@impl true
def handle_event("hide_column_modal", _params, socket) do
{:noreply, assign(socket, :show_column_modal, false)}
end
@impl true
def handle_event("toggle_column", %{"field" => field}, socket) do
current_columns = socket.assigns.selected_columns
available_columns = socket.assigns.available_columns
# Check if column is required
column_meta = Enum.find(available_columns, fn col -> col.field == field end)
if column_meta && column_meta.required do
# Cannot toggle required columns
{:noreply, socket}
else
# Toggle column visibility
updated_columns =
if field in current_columns do
List.delete(current_columns, field)
else
# Add column at the end
current_columns ++ [field]
end
# Save to settings
case TableColumns.update_user_table_columns(updated_columns) do
{:ok, _} ->
{:noreply, assign(socket, :selected_columns, updated_columns)}
{:error, _} ->
{:noreply,
socket
|> put_flash(:error, "Failed to save column preferences")}
end
end
end
@impl true
def handle_event("reorder_columns", params, socket) do
current_columns = socket.assigns.selected_columns
reordered = TableColumns.reorder_columns(current_columns, params)
# Save to settings
case TableColumns.update_user_table_columns(reordered) do
{:ok, _} ->
{:noreply, assign(socket, :selected_columns, reordered)}
{:error, _} ->
{:noreply,
socket
|> put_flash(:error, "Failed to save column order")}
end
end
@impl true
def handle_event("reset_columns", _params, socket) do
default_columns = TableColumns.reset_columns()
# Save to settings
case TableColumns.update_user_table_columns(default_columns) do
{:ok, _} ->
{:noreply,
socket
|> assign(:selected_columns, default_columns)
|> put_flash(:info, "Columns reset to default")}
{:error, _} ->
{:noreply,
socket
|> put_flash(:error, "Failed to reset columns")}
end
end
## --- Info Handlers ---
@impl true
def handle_info({:send_test_email, recipient}, socket) do
case PhoenixKit.Mailer.send_test_tracking_email(recipient) 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,
"Test email sent successfully to #{recipient}! Check your emails to see the management data."
)
|> 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, "Failed to send test email: #{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, "Error sending test email: #{Exception.message(error)}")}
end
## --- Private Helper Functions ---
# Apply default filter values
defp assign_filter_defaults(socket) do
filters = %{
search: "",
status: "",
message_tag: "",
campaign_id: "",
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"] || "",
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)
socket
|> assign(:filters, filters)
|> assign(:page, page)
|> assign(:per_page, per_page)
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
query_filters = build_query_filters(filters, page, per_page)
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
# 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)
{: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,
"from_date" => assigns.filters.from_date,
"to_date" => assigns.filters.to_date,
"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
defp status_badge_class(status) do
case status do
"sent" -> "badge badge-info badge-sm"
"delivered" -> "badge badge-success badge-sm"
"bounced" -> "badge badge-error badge-sm"
"opened" -> "badge badge-warning badge-sm"
"clicked" -> "badge badge-secondary badge-sm"
"failed" -> "badge badge-error badge-sm"
_ -> "badge badge-ghost badge-sm"
end
end
defp pagination_pages(current_page, total_pages) do
start_page = max(1, current_page - 2)
end_page = min(total_pages, current_page + 2)
start_page..end_page
end
defp build_page_url(page, assigns) do
params = build_url_params(assigns, %{"page" => page})
Routes.path("/admin/emails?#{params}")
end
# Extract email_type from message_tags
defp get_message_tag(message_tags) when is_map(message_tags) do
Map.get(message_tags, "email_type")
end
defp get_message_tag(_), do: nil
# Get event timestamp from log's events association
# Falls back to log fields if events not loaded
defp get_event_time(log, event_type) do
case log.events do
# Events loaded - search for matching event type
events when is_list(events) ->
events
|> Enum.find(fn event -> event.event_type == event_type end)
|> case do
nil -> get_fallback_time(log, event_type)
event -> event.occurred_at
end
# Events not loaded - use fallback fields
_ ->
get_fallback_time(log, event_type)
end
end
# Fallback to log timestamp fields if event not found
defp get_fallback_time(log, "delivery"), do: log.delivered_at
defp get_fallback_time(log, "open"), do: log.opened_at
defp get_fallback_time(log, "click"), do: log.clicked_at
defp get_fallback_time(log, "bounce"), do: log.bounced_at
defp get_fallback_time(log, "complaint"), do: log.complained_at
defp get_fallback_time(_log, _type), do: nil
# Builds activity badges list with smart date display
# Each badge shows date only when it differs from previous event
# Returns list of {badge_class, formatted_text, event_type} tuples
defp get_activity_badges(log) do
# Get all event times in chronological order
events = [
{"delivery", get_event_time(log, "delivery"), "badge-success"},
{"open", get_event_time(log, "open"), "badge-warning"},
{"click", get_event_time(log, "click"), "badge-secondary"},
{"bounce", get_event_time(log, "bounce"), "badge-error"},
{"complaint", get_event_time(log, "complaint"), "badge-accent"}
]
# Filter out events that don't exist
existing_events = Enum.filter(events, fn {_type, time, _class} -> time != nil end)
# Start with sent_at date as the reference
initial_date = if log.sent_at, do: DateTime.to_date(log.sent_at), else: nil
# Build badges with smart date display using reduce
{badges, _} =
Enum.reduce(existing_events, {[], initial_date}, fn {type, time, badge_class},
{acc, last_date} ->
formatted_text = format_activity_badge(last_date, time)
new_date = DateTime.to_date(time)
{acc ++ [{badge_class, formatted_text, type}], new_date}
end)
badges
end
# Smart format for activity badges: shows date only if different from previous event
# Compares event with previous shown event in the chain
# If event happened same day as previous -> shows only time (09:14)
# If event happened different day -> shows date+time (21.10 09:14)
defp format_activity_badge(_previous_date, nil), do: ""
defp format_activity_badge(previous_date, event_time) do
time_format = Settings.get_setting("time_format", "H:i")
time_str = UtilsDate.format_time(event_time, time_format)
# Compare event date with previous event date
event_date = DateTime.to_date(event_time)
if Date.compare(previous_date, event_date) == :eq do
# Same day as previous event - show only time
time_str
else
# Different day from previous event - show date + time
date_str = UtilsDate.format_date(event_date, "d.m")
"#{date_str} #{time_str}"
end
end
# Format datetime for compact sent badge (DD.MM HH:MM format)
defp format_sent_badge(nil), do: ""
defp format_sent_badge(datetime) do
time_format = Settings.get_setting("time_format", "H:i")
# Short date format: d.m (day.month)
date_str = UtilsDate.format_date(datetime, "d.m")
time_str = UtilsDate.format_time(datetime, time_format)
"#{date_str} #{time_str}"
end
# 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