rework email settings UX with guided flow and friendly errors
grouped providers by category, added per-provider key validation with cross-provider detection, friendly delivery error messages, retryable vs config error distinction, from-address in general settings, and "Save settings" button to match admin conventions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
||||
saved_adapter = Settings.get_setting("email_adapter")
|
||||
|
||||
adapter_key = current_adapter || saved_adapter
|
||||
grouped = Adapters.grouped()
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
@@ -21,13 +22,14 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
||||
|> assign(:adapter_key, adapter_key)
|
||||
|> assign(:current_values, current_values)
|
||||
|> assign(:all_adapters, Adapters.all())
|
||||
|> assign(:provider_options, provider_options())
|
||||
|> assign(:recommended_adapters, grouped[:all_email] || [])
|
||||
|> assign(:advanced_adapters, grouped[:advanced] || [])
|
||||
|> assign(:email_configured, Mailer.email_configured?())
|
||||
|> assign(
|
||||
:from_address,
|
||||
Settings.get_setting("email_from_address") || socket.assigns.current_scope.user.email
|
||||
)
|
||||
|> assign(:selected_adapter, adapter_key && Adapters.get(adapter_key))
|
||||
|> assign(:sending_test, false)
|
||||
|> assign(:test_result, if(Mailer.email_verified?(), do: :ok))
|
||||
|> assign(:test_error, nil)
|
||||
|> assign(:test_retryable, false)
|
||||
|> assign(:from_checklist, false)
|
||||
|> assign(:field_errors, %{})
|
||||
|> assign(:form, to_form(%{}, as: :email))}
|
||||
@@ -58,18 +60,6 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
||||
end
|
||||
end
|
||||
|
||||
defp provider_options do
|
||||
Enum.map(Adapters.all(), fn adapter ->
|
||||
%{
|
||||
value: adapter.key,
|
||||
name: adapter.name,
|
||||
description: adapter.description,
|
||||
tags: adapter.tags,
|
||||
url: adapter.url
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("change_adapter", %{"email" => %{"adapter" => key}}, socket) do
|
||||
values = load_adapter_values(key)
|
||||
@@ -77,8 +67,11 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:adapter_key, key)
|
||||
|> assign(:selected_adapter, Adapters.get(key))
|
||||
|> assign(:current_values, values)
|
||||
|> assign(:field_errors, %{})}
|
||||
|> assign(:field_errors, %{})
|
||||
|> assign(:test_result, nil)
|
||||
|> assign(:test_error, nil)}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"email" => params}, socket) do
|
||||
@@ -96,50 +89,28 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("disconnect", _params, socket) do
|
||||
if socket.assigns.env_locked do
|
||||
{:noreply, put_flash(socket, :error, "Email config is controlled by environment variables")}
|
||||
else
|
||||
# Clear all email settings
|
||||
Settings.delete_setting("email_adapter")
|
||||
|
||||
for key <- Adapters.all_field_keys() do
|
||||
Settings.delete_setting(key)
|
||||
end
|
||||
|
||||
Mailer.clear_email_verified()
|
||||
|
||||
# Reset to Local adapter
|
||||
Application.put_env(:berrypod, Mailer, adapter: Swoosh.Adapters.Local)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:adapter_key, nil)
|
||||
|> assign(:current_values, %{})
|
||||
|> assign(:email_configured, false)
|
||||
|> put_flash(:info, "Email provider disconnected")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("send_test", _params, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
socket = assign(socket, :sending_test, true)
|
||||
|
||||
case Mailer.send_test_email(user.email, socket.assigns.from_address) do
|
||||
case Mailer.send_test_email(user.email, Mailer.from_address()) do
|
||||
{:ok, _} ->
|
||||
Mailer.mark_email_verified()
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:sending_test, false)
|
||||
|> put_flash(:info, "Test email sent to #{user.email}")}
|
||||
|> assign(:test_result, :ok)
|
||||
|> assign(:test_error, nil)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:sending_test, false)
|
||||
|> put_flash(:error, "Failed to send test email: #{inspect(reason)}")}
|
||||
|> assign(:test_result, :error)
|
||||
|> assign(:test_error, Mailer.friendly_error(reason))
|
||||
|> assign(:test_retryable, Mailer.retryable_error?(reason))}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -183,6 +154,13 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
||||
if field_errors != %{} do
|
||||
{:noreply, assign(socket, :field_errors, field_errors)}
|
||||
else
|
||||
# Clear settings from other providers
|
||||
new_keys = MapSet.new(Adapters.field_keys(adapter_info))
|
||||
|
||||
for key <- Adapters.all_field_keys(), key not in new_keys do
|
||||
Settings.delete_setting(key)
|
||||
end
|
||||
|
||||
# Save adapter type
|
||||
Settings.put_setting("email_adapter", adapter_info.key)
|
||||
|
||||
@@ -207,11 +185,9 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
||||
end
|
||||
end
|
||||
|
||||
# Save from address
|
||||
from_address = params["from_address"] || ""
|
||||
|
||||
if from_address != "" do
|
||||
Settings.put_setting("email_from_address", from_address)
|
||||
# Auto-set from address to admin email if not already configured
|
||||
if Settings.get_setting("email_from_address") in [nil, ""] do
|
||||
Settings.put_setting("email_from_address", socket.assigns.current_scope.user.email)
|
||||
end
|
||||
|
||||
# Config changed — require re-verification
|
||||
@@ -226,11 +202,13 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:adapter_key, current_adapter)
|
||||
|> assign(:selected_adapter, Adapters.get(current_adapter))
|
||||
|> assign(:current_values, current_values)
|
||||
|> assign(:from_address, from_address)
|
||||
|> assign(:email_configured, Mailer.email_configured?())
|
||||
|> assign(:field_errors, %{})
|
||||
|> put_flash(:info, "Email settings saved")}
|
||||
|> assign(:test_result, nil)
|
||||
|> assign(:test_error, nil)
|
||||
|> put_flash(:info, "Settings saved — send a test email to check it works")}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -250,9 +228,8 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
||||
<.header>
|
||||
Email settings
|
||||
<:subtitle>
|
||||
Configure how your shop sends email. <strong>Transactional</strong>
|
||||
providers only handle order confirmations and password resets. <strong>All email</strong>
|
||||
providers also support newsletters and marketing campaigns.
|
||||
Your shop needs an email provider to send order confirmations,
|
||||
shipping updates, and newsletters to your customers.
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
@@ -277,17 +254,44 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
||||
|
||||
<section class="admin-section">
|
||||
<.form for={@form} phx-change="change_adapter" phx-submit="save">
|
||||
<div id="email-provider-cards" phx-hook="CardRadioScroll">
|
||||
<.card_radio_group
|
||||
name="email[adapter]"
|
||||
value={@adapter_key}
|
||||
legend="Email provider"
|
||||
options={@provider_options}
|
||||
disabled={@env_locked}
|
||||
display={:tags}
|
||||
/>
|
||||
<%!-- Step 1: Choose a provider --%>
|
||||
<div class="admin-setup-step">
|
||||
<div class="admin-setup-step-header">
|
||||
<span class="admin-setup-step-number">1</span>
|
||||
<h2 class="admin-setup-step-title">Choose a provider</h2>
|
||||
</div>
|
||||
<p class="admin-setup-step-desc">
|
||||
All of these have a free tier. Pick whichever you like.
|
||||
</p>
|
||||
<div id="email-provider-cards" phx-hook="CardRadioScroll">
|
||||
<fieldset class="card-radio-fieldset" disabled={@env_locked}>
|
||||
<div class="card-radio-grid">
|
||||
<.provider_card
|
||||
:for={adapter <- @recommended_adapters}
|
||||
adapter={adapter}
|
||||
selected={@adapter_key}
|
||||
disabled={@env_locked}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<details class="admin-provider-other">
|
||||
<summary class="admin-provider-other-toggle">
|
||||
Already have your own email server?
|
||||
</summary>
|
||||
<div class="card-radio-grid">
|
||||
<.provider_card
|
||||
:for={adapter <- @advanced_adapters}
|
||||
adapter={adapter}
|
||||
selected={@adapter_key}
|
||||
disabled={@env_locked}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Steps 2 & 3 appear per-adapter after selection --%>
|
||||
<%= for adapter <- @all_adapters do %>
|
||||
<% selected = @adapter_key == adapter.key %>
|
||||
<div
|
||||
@@ -296,82 +300,191 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
||||
hidden={!selected}
|
||||
data-adapter={adapter.key}
|
||||
>
|
||||
<div>
|
||||
<h3 class="admin-section-subheading">
|
||||
{adapter.name}
|
||||
<.external_link
|
||||
:if={adapter.url}
|
||||
href={adapter.url}
|
||||
icon={false}
|
||||
class="admin-link-subtle admin-adapter-link"
|
||||
aria-label={adapter.name <> " website"}
|
||||
>
|
||||
↗
|
||||
</.external_link>
|
||||
</h3>
|
||||
<p class="admin-section-desc">{adapter.description}</p>
|
||||
</div>
|
||||
<.input
|
||||
name="email[from_address]"
|
||||
value={@from_address}
|
||||
type="email"
|
||||
label="From address"
|
||||
placeholder="noreply@yourshop.com"
|
||||
disabled={!selected || @env_locked}
|
||||
/>
|
||||
<%= for field <- adapter.fields do %>
|
||||
<.adapter_field_static
|
||||
field_def={field}
|
||||
value={if selected, do: @current_values[field.key]}
|
||||
disabled={!selected || @env_locked}
|
||||
error={if selected, do: @field_errors[field.key]}
|
||||
/>
|
||||
<% end %>
|
||||
<%= unless @env_locked do %>
|
||||
<div class="admin-row admin-row-lg">
|
||||
<.button phx-disable-with="Saving..." disabled={!selected}>
|
||||
Save settings
|
||||
</.button>
|
||||
<%= if selected && @email_configured do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="disconnect"
|
||||
data-confirm="Remove email configuration? Transactional emails will stop being sent."
|
||||
class="admin-link-danger"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
<% end %>
|
||||
<%!-- Step 2: Create an account (providers with sign-up URLs) --%>
|
||||
<div :if={adapter.url} class="admin-setup-step">
|
||||
<div class="admin-setup-step-header">
|
||||
<span class="admin-setup-step-number">2</span>
|
||||
<h2 class="admin-setup-step-title">Create a free account</h2>
|
||||
</div>
|
||||
<% end %>
|
||||
<p class="admin-setup-step-desc">
|
||||
<.external_link href={adapter.url} class="admin-link">
|
||||
Sign up at {adapter.name} ↗
|
||||
</.external_link>
|
||||
if you don't already have an account. It's free.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%!-- Step 3 (or 2 for advanced): Paste your key --%>
|
||||
<div class="admin-setup-step">
|
||||
<div class="admin-setup-step-header">
|
||||
<span class="admin-setup-step-number">
|
||||
{if adapter.url, do: "3", else: "2"}
|
||||
</span>
|
||||
<h2 class="admin-setup-step-title">{adapter_fields_title(adapter)}</h2>
|
||||
</div>
|
||||
<p class="admin-setup-step-desc">{adapter_fields_instruction(adapter)}</p>
|
||||
<%= for field <- adapter.fields do %>
|
||||
<.adapter_field_static
|
||||
field_def={field}
|
||||
value={if selected, do: @current_values[field.key]}
|
||||
disabled={!selected || @env_locked}
|
||||
error={if selected, do: @field_errors[field.key]}
|
||||
/>
|
||||
<% end %>
|
||||
<%= unless @env_locked do %>
|
||||
<div class="admin-row admin-row-lg">
|
||||
<.button phx-disable-with="Saving..." disabled={!selected}>
|
||||
Save settings
|
||||
</.button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</.form>
|
||||
</section>
|
||||
|
||||
<%= if @email_configured do %>
|
||||
<section class="admin-section-bordered">
|
||||
<h2 class="admin-section-heading">Test email</h2>
|
||||
<p class="admin-help-text">
|
||||
Send a test email to <strong>{@current_scope.user.email}</strong>
|
||||
to verify delivery works.
|
||||
<%!-- Step 4: Send a test email (only after config saved) --%>
|
||||
<div :if={@email_configured} class="admin-setup-step" style="margin-top: 1.5rem;">
|
||||
<div class="admin-setup-step-header">
|
||||
<span class={[
|
||||
"admin-setup-step-number",
|
||||
@test_result == :ok && "admin-setup-step-number-done",
|
||||
@test_result == :error && "admin-setup-step-number-error"
|
||||
]}>
|
||||
<%= cond do %>
|
||||
<% @test_result == :ok -> %>
|
||||
<.icon name="hero-check-mini" class="size-4" />
|
||||
<% @test_result == :error -> %>
|
||||
<.icon name="hero-x-mark-mini" class="size-4" />
|
||||
<% true -> %>
|
||||
{if @selected_adapter && @selected_adapter.url, do: "4", else: "3"}
|
||||
<% end %>
|
||||
</span>
|
||||
<h2 class="admin-setup-step-title">
|
||||
<%= cond do %>
|
||||
<% @test_result == :ok -> %>
|
||||
Email is working
|
||||
<% @test_result == :error -> %>
|
||||
Test failed
|
||||
<% true -> %>
|
||||
Send a test email
|
||||
<% end %>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<%= if @test_result == :ok do %>
|
||||
<p class="admin-setup-step-desc">
|
||||
Test email sent to <strong>{@current_scope.user.email}</strong>.
|
||||
Check your inbox to confirm it arrived.
|
||||
</p>
|
||||
<div class="admin-section-body">
|
||||
<button
|
||||
phx-click="send_test"
|
||||
disabled={@sending_test}
|
||||
class="admin-btn admin-btn-outline"
|
||||
<div class="admin-row admin-row-sm">
|
||||
<.link
|
||||
:if={@from_checklist}
|
||||
navigate={~p"/admin"}
|
||||
class="admin-btn admin-btn-primary admin-btn-sm"
|
||||
>
|
||||
<.icon name="hero-paper-airplane" class="size-4" />
|
||||
{if @sending_test, do: "Sending...", else: "Send test email"}
|
||||
</button>
|
||||
Continue setup →
|
||||
</.link>
|
||||
<.button type="button" phx-click="send_test">
|
||||
Send again
|
||||
</.button>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= if @test_result == :error do %>
|
||||
<p class="admin-test-error">{@test_error}</p>
|
||||
<div>
|
||||
<%= if @test_retryable do %>
|
||||
<button
|
||||
phx-click="send_test"
|
||||
disabled={@sending_test}
|
||||
class="admin-btn admin-btn-outline admin-btn-error"
|
||||
>
|
||||
<.icon name="hero-paper-airplane" class="size-4" />
|
||||
{if @sending_test, do: "Sending...", else: "Try again"}
|
||||
</button>
|
||||
<% else %>
|
||||
<p class="admin-setup-step-desc">
|
||||
Fix your settings above and reconnect, then try the test again.
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="admin-setup-step-desc">
|
||||
Send a test to <strong>{@current_scope.user.email}</strong> to check everything works.
|
||||
</p>
|
||||
<div>
|
||||
<button
|
||||
phx-click="send_test"
|
||||
disabled={@sending_test}
|
||||
class="admin-btn admin-btn-outline"
|
||||
>
|
||||
<.icon name="hero-paper-airplane" class="size-4" />
|
||||
{if @sending_test, do: "Sending...", else: "Send test email"}
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# ── Local components ──
|
||||
|
||||
defp adapter_fields_title(%{key: "smtp"}), do: "Enter your server details"
|
||||
defp adapter_fields_title(%{key: "postal"}), do: "Enter your server details"
|
||||
defp adapter_fields_title(%{key: "mailjet"}), do: "Paste your API keys"
|
||||
defp adapter_fields_title(_adapter), do: "Paste your API key"
|
||||
|
||||
defp adapter_fields_instruction(%{key: "smtp"}),
|
||||
do: "Enter your SMTP server connection details below."
|
||||
|
||||
defp adapter_fields_instruction(%{key: "postal"}),
|
||||
do: "Enter your Postal server URL and API key below."
|
||||
|
||||
defp adapter_fields_instruction(%{key: "mailjet"}),
|
||||
do: "Find your API key and secret key under API Key Management in your Mailjet account."
|
||||
|
||||
defp adapter_fields_instruction(%{key: "mailgun"}),
|
||||
do: "Find your API key in your Mailgun dashboard, and enter your sending domain."
|
||||
|
||||
defp adapter_fields_instruction(adapter),
|
||||
do: "Find your API key in your #{adapter.name} account settings and paste it here."
|
||||
|
||||
attr :adapter, :map, required: true
|
||||
attr :selected, :string, default: nil
|
||||
attr :disabled, :boolean, default: false
|
||||
|
||||
defp provider_card(assigns) do
|
||||
~H"""
|
||||
<label class={[
|
||||
"card-radio-card",
|
||||
@selected == @adapter.key && "card-radio-card-selected"
|
||||
]}>
|
||||
<input
|
||||
type="radio"
|
||||
id={"email-adapter-#{@adapter.key}"}
|
||||
name="email[adapter]"
|
||||
value={@adapter.key}
|
||||
checked={@selected == @adapter.key}
|
||||
disabled={@disabled}
|
||||
class="card-radio-input"
|
||||
/>
|
||||
<span class="card-radio-name">
|
||||
{@adapter.name}
|
||||
<span :if={@adapter.recommended} class="card-radio-badge card-radio-recommended">
|
||||
Recommended
|
||||
</span>
|
||||
</span>
|
||||
<span :if={@adapter.free_tier} class="card-radio-description">{@adapter.free_tier}</span>
|
||||
<span :if={@adapter.setup_hint} class="card-radio-description">{@adapter.setup_hint}</span>
|
||||
</label>
|
||||
"""
|
||||
end
|
||||
|
||||
# ── Field renderers ──
|
||||
|
||||
attr :field_def, :map, required: true
|
||||
attr :value, :any, default: nil
|
||||
attr :disabled, :boolean, default: false
|
||||
@@ -381,24 +494,17 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
||||
assigns = assign(assigns, :errors, if(assigns.error, do: [assigns.error], else: []))
|
||||
|
||||
~H"""
|
||||
<div>
|
||||
<.input
|
||||
name={"email[#{@field_def.key}]"}
|
||||
value=""
|
||||
type="text"
|
||||
label={@field_def.label}
|
||||
autocomplete="off"
|
||||
placeholder={if @value, do: @value, else: ""}
|
||||
required={@field_def.required && !@value}
|
||||
disabled={@disabled}
|
||||
errors={@errors}
|
||||
/>
|
||||
<%= if @value && !@disabled do %>
|
||||
<p class="admin-help-text">
|
||||
Current: <code>{@value}</code> — leave blank to keep existing value
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<.input
|
||||
name={"email[#{@field_def.key}]"}
|
||||
value=""
|
||||
type="text"
|
||||
label={@field_def.label}
|
||||
autocomplete="off"
|
||||
placeholder={@value || ""}
|
||||
required={@field_def.required && !@value}
|
||||
disabled={@disabled}
|
||||
errors={@errors}
|
||||
/>
|
||||
"""
|
||||
end
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
|> assign(:page_title, "Settings")
|
||||
|> assign(:site_live, Settings.site_live?())
|
||||
|> assign(:cart_recovery_enabled, Settings.abandoned_cart_recovery_enabled?())
|
||||
|> assign(:from_address, Settings.get_setting("email_from_address") || user.email)
|
||||
|> assign_stripe_state()
|
||||
|> assign_products_state()
|
||||
|> assign_account_state(user)}
|
||||
@@ -108,6 +109,23 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
|> put_flash(:info, message)}
|
||||
end
|
||||
|
||||
# -- Events: from address --
|
||||
|
||||
def handle_event("save_from_address", %{"from_address" => address}, socket) do
|
||||
address = String.trim(address)
|
||||
|
||||
if address != "" do
|
||||
Settings.put_setting("email_from_address", address)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:from_address, address)
|
||||
|> put_flash(:info, "From address saved")}
|
||||
else
|
||||
{:noreply, put_flash(socket, :error, "From address can't be blank")}
|
||||
end
|
||||
end
|
||||
|
||||
# -- Events: Stripe --
|
||||
|
||||
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
|
||||
@@ -415,6 +433,25 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- From address --%>
|
||||
<section class="admin-section">
|
||||
<h2 class="admin-section-title">From address</h2>
|
||||
<p class="admin-section-desc">
|
||||
The sender address on all emails from your shop.
|
||||
</p>
|
||||
<div class="admin-section-body">
|
||||
<form phx-submit="save_from_address" class="admin-row admin-row-lg">
|
||||
<.input
|
||||
name="from_address"
|
||||
value={@from_address}
|
||||
type="email"
|
||||
placeholder="noreply@yourshop.com"
|
||||
/>
|
||||
<.button phx-disable-with="Saving...">Save</.button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Account --%>
|
||||
<section class="admin-section">
|
||||
<h2 class="admin-section-title">Account</h2>
|
||||
|
||||
Reference in New Issue
Block a user