Packages

phoenix_kit

1.7.219
1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 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 settings email_sending.ex
Raw

lib/phoenix_kit_web/live/settings/email_sending.ex

defmodule PhoenixKitWeb.Live.Settings.EmailSending do
@moduledoc """
Core "Email Sending" admin settings page (`/admin/settings/email-sending`).
Covers what core owns about outbound email: sender identity, which
transport is actually in effect (static app-config mailer vs. a
connected Integrations provider), the operator's choice of default send
integration, and a test-send action. Send Profiles (per-account sender
identity, rate limits, provider-specific options) live one level down
at `/admin/settings/email-sending/profiles`.
## Path note
This page is deliberately at `email-sending`, not `emails` — the
optional `phoenix_kit_emails` module registers its own routable
"Emails" settings tab at `/admin/settings/emails` (via its
`settings_tabs/0`). The two pages coexist under different paths for
now; a later change (Stage 1 task A5) will collapse them into one.
## Module-contributed sections
Modules can extend this page without the core page knowing anything
about them, via `PhoenixKit.Module.email_settings_sections/0` — see
`PhoenixKit.ModuleRegistry.all_email_settings_sections/0`. Each
section is a module-owned `Phoenix.LiveComponent`, rendered below the
core sections, gated by its declared permission (or shown to any admin
when the permission is `nil`).
"""
use PhoenixKitWeb, :live_view
use Gettext, backend: PhoenixKitWeb.Gettext
alias PhoenixKit.Config
alias PhoenixKit.Integrations
alias PhoenixKit.Integrations.Providers
alias PhoenixKit.Mailer
alias PhoenixKit.ModuleRegistry
alias PhoenixKit.Settings
alias PhoenixKit.Users.Auth.Scope
alias PhoenixKit.Utils.Routes
@default_integration_setting "default_email_integration_uuid"
def mount(_params, _session, socket) do
socket =
socket
|> assign(:page_title, gettext("Email Sending"))
|> assign(
:page_subtitle,
gettext(
"Sender identity, transport, and the default integration used to deliver outbound email"
)
)
|> assign(:project_title, Settings.get_project_title())
|> assign(:current_path, get_current_path(socket.assigns.current_locale_base))
|> assign_sender_identity()
|> assign_transport_info()
|> assign_email_integrations()
|> assign_default_integration()
|> assign_email_settings_sections()
{:ok, socket}
end
def handle_params(_params, _url, socket) do
{:noreply, socket}
end
# ---------------------------------------------------------------------------
# Events
# ---------------------------------------------------------------------------
def handle_event("save_sender_identity", %{"from_name" => name, "from_email" => email}, socket) do
name = String.trim(name)
email = String.trim(email)
with {:ok, _} <- Settings.update_setting("from_name", name),
{:ok, _} <- Settings.update_setting("from_email", email) do
socket = assign_sender_identity(socket)
# Saved either way — a host may genuinely want a local-only sender — but
# say so at the moment of the change, not only in the banner above.
{kind, message} =
if socket.assigns.sender_loggable? do
{:info, gettext("Sender identity updated")}
else
{:warning,
gettext(
"Sender identity updated, but %{email} is not a full address — the email tracking module will silently skip logging messages sent from it.",
email: socket.assigns.effective_from_email
)}
end
{:noreply, put_flash(socket, kind, message)}
else
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, gettext("Could not save sender identity"))}
end
end
def handle_event("select_default_integration", %{"integration_uuid" => uuid}, socket) do
case Settings.update_setting(@default_integration_setting, uuid) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, gettext("Default send integration updated"))
|> assign_default_integration()}
{:error, _changeset} ->
{:noreply,
put_flash(socket, :error, gettext("Could not update default send integration"))}
end
end
def handle_event("send_test_email", %{"recipient" => recipient}, socket) do
case String.trim(recipient) do
"" ->
{:noreply, put_flash(socket, :error, gettext("Enter a recipient email address"))}
recipient ->
send_test_email(socket, recipient)
end
end
defp send_test_email(socket, recipient) do
email =
Swoosh.Email.new()
|> Swoosh.Email.to(recipient)
|> Swoosh.Email.from({Mailer.get_from_name(), Mailer.get_from_email()})
|> Swoosh.Email.subject(
gettext("Test email from %{site}", site: socket.assigns.project_title)
)
|> Swoosh.Email.text_body(
gettext("This is a test email sent from the Email Sending settings page.")
)
case Mailer.deliver_email(email) do
{:ok, _result} ->
{:noreply,
put_flash(
socket,
:info,
gettext("Test email sent to %{recipient}", recipient: recipient)
)}
{:error, {:incomplete_credentials, missing_fields}} ->
{:noreply,
put_flash(
socket,
:error,
gettext(
"Could not send test email: the send integration is missing required field(s): %{fields}",
fields: Enum.map_join(missing_fields, ", ", &to_string/1)
)
)}
{:error, reason} ->
{:noreply,
put_flash(
socket,
:error,
gettext("Could not send test email: %{reason}", reason: inspect(reason))
)}
end
end
# ---------------------------------------------------------------------------
# Private — assigns
# ---------------------------------------------------------------------------
defp assign_sender_identity(socket) do
effective_from_email = Mailer.get_from_email()
socket
|> assign(:from_name, Settings.get_setting("from_name", ""))
|> assign(:from_email, Settings.get_setting("from_email", ""))
|> assign(:effective_from_name, Mailer.get_from_name())
|> assign(:effective_from_email, effective_from_email)
|> assign(:sender_loggable?, loggable_sender?(effective_from_email))
end
# The email-tracking module refuses to log a message whose `from` is not a
# full address — its Log changeset validates ~r/^[^\s]+@[^\s]+\.[^\s]+$/ — and
# the interceptor swallows that rejection into a log line. The built-in default
# `noreply@localhost` fails it, so a freshly enabled email system records
# nothing at all while mail keeps going out: no rows, no error, nothing in the
# UI. Mirroring the rule here turns that silent dead end into a warning on the
# page that owns the address. Deliberately a copy and not a call: core must not
# depend on the optional module (and the check is one regex).
#
# One clause, not a guarded pair: `Mailer.get_from_email/0` is the only caller
# and always hands back a binary, so a `when is_binary/1` head plus a catch-all
# is dead code dialyzer fails the build over (pattern_match_cov). `to_string/1`
# keeps the same defensiveness without the unreachable clause — a nil settings
# value becomes "", which the regex rejects.
defp loggable_sender?(email),
do: Regex.match?(~r/^[^\s]+@[^\s]+\.[^\s]+$/, to_string(email))
defp assign_transport_info(socket) do
mailer = Mailer.get_mailer()
built_in? = mailer == PhoenixKit.Mailer
config =
if built_in?,
do: Config.get(mailer, []),
else: Config.get_parent_app_config(mailer, [])
socket
|> assign(:mailer_module, mailer)
|> assign(:mailer_built_in?, built_in?)
|> assign(:mailer_adapter, Keyword.get(config, :adapter))
end
# Single query for all email-capable providers' connections, mirroring
# `PhoenixKitWeb.Live.Settings.Integrations.load_connections/1`.
defp assign_email_integrations(socket) do
providers = Providers.with_capability(:email_send)
provider_keys = Enum.map(providers, & &1.key)
providers_by_key = Map.new(providers, &{&1.key, &1})
all_connections = Integrations.load_all_connections(provider_keys)
connections =
Enum.flat_map(providers, fn provider ->
all_connections
|> Map.get(provider.key, [])
|> Enum.map(fn %{uuid: uuid, name: name, data: data} ->
%{provider: providers_by_key[provider.key], uuid: uuid, name: name, data: data}
end)
end)
assign(socket, :email_connections, connections)
end
defp assign_default_integration(socket) do
assign(
socket,
:default_integration_uuid,
Settings.get_setting(@default_integration_setting, "")
)
end
defp assign_email_settings_sections(socket) do
scope = socket.assigns[:phoenix_kit_current_scope]
sections =
ModuleRegistry.all_email_settings_sections()
|> Enum.filter(&section_visible?(&1, scope))
assign(socket, :email_settings_sections, sections)
end
defp section_visible?(%{permission: nil}, _scope), do: true
defp section_visible?(%{permission: permission}, scope),
do: Scope.has_module_access?(scope, permission)
# ---------------------------------------------------------------------------
# Private — template helpers
# ---------------------------------------------------------------------------
defp integration_status_badge("connected"), do: {"badge-success", gettext("Connected")}
defp integration_status_badge("configured"), do: {"badge-warning", gettext("Not tested")}
defp integration_status_badge("disconnected"), do: {"badge-ghost", gettext("Not connected")}
defp integration_status_badge("error"), do: {"badge-error", gettext("Error")}
defp integration_status_badge(_), do: {"badge-ghost", gettext("Not configured")}
defp get_current_path(locale) do
Routes.path("/admin/settings/email-sending", locale: locale)
end
end