berrypod/lib/berrypod_web/live/admin/email_settings.ex
jamey 7547d0d4b8 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>
2026-03-04 17:12:10 +00:00

543 lines
18 KiB
Elixir

defmodule BerrypodWeb.Admin.EmailSettings do
use BerrypodWeb, :live_view
alias Berrypod.KeyValidation
alias Berrypod.Mailer
alias Berrypod.Mailer.Adapters
alias Berrypod.Settings
@impl true
def mount(_params, _session, socket) do
env_locked = Mailer.env_var_configured?()
{current_adapter, current_values} = Mailer.current_config()
saved_adapter = Settings.get_setting("email_adapter")
adapter_key = current_adapter || saved_adapter
grouped = Adapters.grouped()
{:ok,
socket
|> assign(:page_title, "Email settings")
|> assign(:env_locked, env_locked)
|> assign(:adapter_key, adapter_key)
|> assign(:current_values, current_values)
|> assign(:all_adapters, Adapters.all())
|> assign(:recommended_adapters, grouped[:all_email] || [])
|> assign(:advanced_adapters, grouped[:advanced] || [])
|> assign(:email_configured, Mailer.email_configured?())
|> 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))}
end
@impl true
def handle_params(params, _uri, socket) do
{:noreply, assign(socket, :from_checklist, params["from"] == "checklist")}
end
defp load_adapter_values(adapter_key) do
case Adapters.get(adapter_key) do
nil ->
%{}
adapter_info ->
for field <- adapter_info.fields, into: %{} do
settings_key = Adapters.settings_key(adapter_key, field.key)
value =
case field.type do
:secret -> Settings.secret_hint(settings_key)
_ -> Settings.get_setting(settings_key)
end
{field.key, value}
end
end
end
@impl true
def handle_event("change_adapter", %{"email" => %{"adapter" => key}}, socket) do
values = load_adapter_values(key)
{:noreply,
socket
|> assign(:adapter_key, key)
|> assign(:selected_adapter, Adapters.get(key))
|> assign(:current_values, values)
|> assign(:field_errors, %{})
|> assign(:test_result, nil)
|> assign(:test_error, nil)}
end
def handle_event("save", %{"email" => params}, socket) do
if socket.assigns.env_locked do
{:noreply, put_flash(socket, :error, "Email config is controlled by environment variables")}
else
adapter_key = params["adapter"]
adapter_info = Adapters.get(adapter_key)
if adapter_info do
save_adapter_config(socket, adapter_info, params)
else
{:noreply, put_flash(socket, :error, "Please select an email provider")}
end
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, Mailer.from_address()) do
{:ok, _} ->
Mailer.mark_email_verified()
{:noreply,
socket
|> assign(:sending_test, false)
|> assign(:test_result, :ok)
|> assign(:test_error, nil)}
{:error, reason} ->
{:noreply,
socket
|> assign(:sending_test, false)
|> assign(:test_result, :error)
|> assign(:test_error, Mailer.friendly_error(reason))
|> assign(:test_retryable, Mailer.retryable_error?(reason))}
end
end
defp save_adapter_config(socket, adapter_info, params) do
# Trim all values
params = Map.new(params, fn {k, v} -> {k, if(is_binary(v), do: String.trim(v), else: v)} end)
# Validate required fields
missing =
adapter_info.fields
|> Enum.filter(& &1.required)
|> Enum.filter(fn field ->
val = params[field.key]
empty = is_nil(val) or val == ""
settings_key = Adapters.settings_key(adapter_info.key, field.key)
# Secret fields can be left blank to keep existing value
empty and not (field.type == :secret and Settings.get_secret(settings_key) != nil)
end)
# Build per-field errors for missing required fields
missing_errors =
for field <- missing, into: %{} do
{field.key, "#{field.label} is required"}
end
# Validate secret field formats for known providers
format_errors =
for field <- adapter_info.fields,
field.type == :secret,
value = params[field.key],
value != nil and value != "",
{:error, message} <- [
KeyValidation.validate_email_key(value, adapter_info.key, field.key)
],
into: %{} do
{field.key, message}
end
field_errors = Map.merge(missing_errors, format_errors)
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)
# Save current adapter fields (blank secrets keep existing value)
for field <- adapter_info.fields do
value = params[field.key]
settings_key = Adapters.settings_key(adapter_info.key, field.key)
cond do
value && value != "" ->
case field.type do
:secret -> Settings.put_secret(settings_key, value)
:integer -> Settings.put_setting(settings_key, String.to_integer(value), "integer")
_ -> Settings.put_setting(settings_key, value)
end
field.type == :secret ->
:keep
true ->
Settings.delete_setting(settings_key)
end
end
# 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
Mailer.clear_email_verified()
# Apply config immediately
Mailer.load_config()
# Re-read current state
{current_adapter, current_values} = Mailer.current_config()
{:noreply,
socket
|> assign(:adapter_key, current_adapter)
|> assign(:selected_adapter, Adapters.get(current_adapter))
|> assign(:current_values, current_values)
|> assign(:email_configured, Mailer.email_configured?())
|> assign(:field_errors, %{})
|> assign(:test_result, nil)
|> assign(:test_error, nil)
|> put_flash(:info, "Settings saved — send a test email to check it works")}
end
end
@impl true
def render(assigns) do
~H"""
<div class="admin-content-medium">
<div :if={@from_checklist} class="admin-checklist-banner">
<.icon name="hero-clipboard-document-check" class="size-5 admin-checklist-banner-icon" />
<span class="admin-checklist-banner-text">
You're setting up email for your shop.
</span>
<.link navigate={~p"/admin"} class="admin-link admin-checklist-banner-link">
&larr; Back to checklist
</.link>
</div>
<.header>
Email settings
<:subtitle>
Your shop needs an email provider to send order confirmations,
shipping updates, and newsletters to your customers.
</:subtitle>
</.header>
<%= if @env_locked do %>
<div class="admin-callout-warning">
<div class="admin-callout-warning-body">
<span class="admin-callout-warning-icon">
<.icon name="hero-lock-closed" class="size-5" />
</span>
<div>
<p class="admin-callout-warning-title">
Controlled by environment variables
</p>
<p class="admin-callout-warning-desc">
Email is configured via <code>SMTP_HOST</code> and related env vars.
Remove them to configure email from this page instead.
</p>
</div>
</div>
</div>
<% end %>
<section class="admin-section">
<.form for={@form} phx-change="change_adapter" phx-submit="save">
<%!-- 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
id={"adapter-config-#{adapter.key}"}
class="admin-adapter-config"
hidden={!selected}
data-adapter={adapter.key}
>
<%!-- 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>
<p class="admin-setup-step-desc">
<.external_link href={adapter.url} class="admin-link">
Sign up at {adapter.name} &nearr;
</.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>
<%!-- 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-row admin-row-sm">
<.link
:if={@from_checklist}
navigate={~p"/admin"}
class="admin-btn admin-btn-primary admin-btn-sm"
>
Continue setup &rarr;
</.link>
<.button type="button" phx-click="send_test">
Send again
</.button>
</div>
<% 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
attr :error, :string, default: nil
defp adapter_field_static(%{field_def: %{type: :secret}} = assigns) do
assigns = assign(assigns, :errors, if(assigns.error, do: [assigns.error], else: []))
~H"""
<.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
defp adapter_field_static(%{field_def: %{type: :integer}} = assigns) do
assigns = assign(assigns, :errors, if(assigns.error, do: [assigns.error], else: []))
~H"""
<.input
name={"email[#{@field_def.key}]"}
value={@value || @field_def.default || ""}
type="number"
label={@field_def.label}
required={@field_def.required}
disabled={@disabled}
errors={@errors}
/>
"""
end
defp adapter_field_static(assigns) do
assigns = assign(assigns, :errors, if(assigns.error, do: [assigns.error], else: []))
~H"""
<.input
name={"email[#{@field_def.key}]"}
value={@value || @field_def.default || ""}
type="text"
label={@field_def.label}
required={@field_def.required}
disabled={@disabled}
errors={@errors}
/>
"""
end
end