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:
jamey
2026-03-04 17:12:10 +00:00
parent 67a26eb6b4
commit 7547d0d4b8
11 changed files with 1004 additions and 269 deletions

View File

@@ -80,18 +80,21 @@ defmodule Berrypod.KeyValidation do
# ── Email provider format checks ──
# Known prefixes for cross-provider detection
@known_prefixes [
{"SG.", "SendGrid"},
{"xkeysib-", "Brevo"},
{"re_", "Resend"},
{"key-", "Mailgun"},
{"mlsn.", "MailerSend"}
]
# SendGrid: SG.{id}.{secret}
defp validate_email_format(key, "sendgrid", "api_key") do
cond do
String.starts_with?(key, "SG.") ->
{:ok, key}
String.contains?(key, ".") ->
{:error, "SendGrid keys start with SG. Check you copied the full key"}
true ->
{:error,
"SendGrid API keys start with SG. (like SG.xxxxx.xxxxx). This doesn't look right"}
String.starts_with?(key, "SG.") -> {:ok, key}
wrong = wrong_provider_hint(key, "sendgrid") -> {:error, wrong}
true -> {:error, "SendGrid API keys start with SG. — find yours in Settings → API Keys"}
end
end
@@ -99,56 +102,147 @@ defmodule Berrypod.KeyValidation do
defp validate_email_format(key, "postmark", "api_key") do
uuid_pattern = ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
if Regex.match?(uuid_pattern, key) do
{:ok, key}
else
{:error,
"Postmark tokens are UUIDs (like abc12345-abcd-1234-abcd-123456789abc). Check you copied the server token, not the account token ID"}
cond do
Regex.match?(uuid_pattern, key) ->
{:ok, key}
wrong = wrong_provider_hint(key, "postmark") ->
{:error, wrong}
true ->
{:error,
"Postmark server tokens are UUIDs (like abc12345-abcd-1234-abcd-123456789abc). Make sure you copy the server token, not the account ID"}
end
end
# Resend: re_ prefix
defp validate_email_format(key, "resend", "api_key") do
if String.starts_with?(key, "re_") do
{:ok, key}
else
{:error, "Resend API keys start with re_. Check you copied the full key"}
cond do
String.starts_with?(key, "re_") ->
{:ok, key}
wrong = wrong_provider_hint(key, "resend") ->
{:error, wrong}
true ->
{:error,
"Resend API keys start with re_ — find yours in your Resend dashboard under API Keys"}
end
end
# Mailgun: key- prefix (classic keys)
# Mailgun: key- prefix (classic) or long RBAC keys
defp validate_email_format(key, "mailgun", "api_key") do
cond do
String.starts_with?(key, "key-") ->
{:ok, key}
# Newer RBAC keys don't have a known prefix, let them through
String.length(key) >= 20 ->
{:ok, key}
wrong = wrong_provider_hint(key, "mailgun") ->
{:error, wrong}
true ->
{:error, "Mailgun API keys usually start with key-. Check you copied the full key"}
{:error,
"Mailgun API keys usually start with key- — find yours in Settings → API Security"}
end
end
# Brevo: xkeysib- prefix
defp validate_email_format(key, "brevo", "api_key") do
if String.starts_with?(key, "xkeysib-") do
{:ok, key}
else
{:error, "Brevo API keys start with xkeysib-. Check you copied the full key"}
cond do
String.starts_with?(key, "xkeysib-") ->
{:ok, key}
wrong = wrong_provider_hint(key, "brevo") ->
{:error, wrong}
true ->
{:error,
"Brevo API keys start with xkeysib- — find yours under SMTP & API in your Brevo account"}
end
end
# Unknown provider or non-api_key field, basic length check only
# Mailjet: api_key and secret are both 32-char alphanumeric
defp validate_email_format(key, "mailjet", "api_key") do
cond do
String.length(key) >= 20 ->
{:ok, key}
wrong = wrong_provider_hint(key, "mailjet") ->
{:error, wrong}
true ->
{:error,
"This looks too short for a Mailjet API key — find yours under API Key Management in your Mailjet account"}
end
end
defp validate_email_format(key, "mailjet", "secret") do
cond do
String.length(key) >= 20 ->
{:ok, key}
wrong = wrong_provider_hint(key, "mailjet") ->
{:error, wrong}
true ->
{:error,
"This looks too short for a Mailjet secret key — it's shown alongside your API key in API Key Management"}
end
end
# MailerSend: mlsn. prefix
defp validate_email_format(key, "mailersend", "api_key") do
cond do
String.starts_with?(key, "mlsn.") ->
{:ok, key}
String.length(key) >= 40 ->
{:ok, key}
wrong = wrong_provider_hint(key, "mailersend") ->
{:error, wrong}
true ->
{:error,
"MailerSend API tokens usually start with mlsn. — generate one under Domains → Manage → API Tokens"}
end
end
# MailPace: server token, no known prefix
defp validate_email_format(key, "mailpace", "api_key") do
cond do
String.length(key) >= 10 ->
{:ok, key}
wrong = wrong_provider_hint(key, "mailpace") ->
{:error, wrong}
true ->
{:error,
"This looks too short — find your server API token under your domain settings in MailPace"}
end
end
# Non-api_key fields (domain, relay, base_url, etc.), basic checks
defp validate_email_format(key, _adapter_key, _field_key) do
if String.length(key) < 5 do
{:error, "This value looks too short. Check you copied the full key"}
if String.length(key) < 3 do
{:error, "This value looks too short"}
else
{:ok, key}
end
end
# Detects when a key meant for another provider is pasted into the wrong field
defp wrong_provider_hint(key, current_adapter) do
Enum.find_value(@known_prefixes, fn {prefix, name} ->
if String.starts_with?(key, prefix) and current_adapter != String.downcase(name) do
"This looks like a #{name} key, not the right one for this provider"
end
end)
end
# ── Helpers ──
defp trim(nil), do: ""

View File

@@ -132,6 +132,101 @@ defmodule Berrypod.Mailer do
Settings.get_setting("email_from_address") || "noreply@#{default_from_domain()}"
end
@doc """
Turns a raw delivery error into a user-friendly message.
Swoosh adapters return varied error shapes — HTTP status tuples,
SMTP failure atoms, network errors, etc. This normalises them into
a single readable string for the admin UI.
"""
def friendly_error({status, body}) when is_integer(status) do
message = extract_message(body)
case status do
401 ->
"Your API key was rejected" <>
if(message, do: "#{message}", else: ". Double-check you pasted the right key")
403 ->
"Your API key doesn't have permission to send email" <>
if(message, do: "#{message}", else: "")
404 ->
"Provider endpoint not found" <>
if(message, do: "#{message}", else: ". Check your config")
422 ->
"The provider rejected the request" <> if(message, do: "#{message}", else: "")
429 ->
"Rate limit reached. Wait a moment and try again"
status when status >= 500 ->
"The email provider is having issues (#{status}). Try again in a few minutes"
_ ->
"Provider returned error #{status}" <> if(message, do: "#{message}", else: "")
end
end
# SMTP errors: {:permanent_failure, message} or {:temporary_failure, message}
def friendly_error({:permanent_failure, msg}) when is_binary(msg),
do: "SMTP server rejected the email: #{truncate(msg)}"
def friendly_error({:temporary_failure, msg}) when is_binary(msg),
do: "SMTP server temporarily unavailable: #{truncate(msg)}"
def friendly_error({:retries_exceeded, _}),
do: "SMTP connection failed after retries. Check your server address and port"
# SMTP connection errors
def friendly_error({:no_more_hosts, _}),
do: "Couldn't connect to the SMTP server. Check the hostname and port"
def friendly_error({:auth_failed, _}), do: "SMTP login failed. Check your username and password"
# Network-level errors
def friendly_error(:timeout), do: "Connection timed out. Check your internet connection"
def friendly_error(:econnrefused), do: "Connection refused. Check the server address and port"
def friendly_error(:nxdomain), do: "Server hostname not found. Check the address"
def friendly_error(:closed), do: "Connection was closed. The server may be down"
# Catch-all
def friendly_error(other) when is_binary(other), do: other
def friendly_error(other), do: "Delivery failed: #{inspect(other)}"
@doc """
Returns whether a delivery error is transient (worth retrying).
Config errors (bad key, wrong permissions) need a fix, not a retry.
Transient errors (rate limits, timeouts, server issues) may resolve on their own.
"""
def retryable_error?({status, _}) when is_integer(status), do: status in [429] or status >= 500
def retryable_error?({:temporary_failure, _}), do: true
def retryable_error?({:retries_exceeded, _}), do: true
def retryable_error?(:timeout), do: true
def retryable_error?(:closed), do: true
def retryable_error?(_), do: false
# Extract a human-readable message from various API error body shapes
defp extract_message(%{"message" => msg}) when is_binary(msg), do: downcase_first(msg)
defp extract_message(%{"ErrorMessage" => msg}) when is_binary(msg), do: downcase_first(msg)
defp extract_message(%{"error" => %{"message" => msg}}) when is_binary(msg),
do: downcase_first(msg)
defp extract_message(%{"errors" => [%{"message" => msg} | _]}) when is_binary(msg),
do: downcase_first(msg)
defp extract_message(body) when is_binary(body) and byte_size(body) < 200, do: body
defp extract_message(_), do: nil
defp downcase_first(<<first::utf8, rest::binary>>), do: String.downcase(<<first::utf8>>) <> rest
defp downcase_first(str), do: str
defp truncate(msg) when byte_size(msg) > 120, do: binary_part(msg, 0, 120) <> "..."
defp truncate(msg), do: msg
# Build Swoosh config keyword list from Settings for a given adapter
defp build_config(adapter_info) do
opts =

View File

@@ -1,5 +1,17 @@
defmodule Berrypod.Mailer.Adapter do
@moduledoc false
@enforce_keys [:key, :name, :module, :description, :tags, :fields]
defstruct [:key, :name, :module, :description, :tags, :fields, url: nil]
@enforce_keys [:key, :name, :module, :description, :fields]
defstruct [
:key,
:name,
:module,
:description,
:fields,
:free_tier,
:setup_hint,
tags: [],
url: nil,
category: :all_email,
recommended: false
]
end

View File

@@ -9,20 +9,21 @@ defmodule Berrypod.Mailer.Adapters do
alias Berrypod.Mailer.{Adapter, Field}
# Ordered by capability: transactional + marketing first,
# then transactional only, then self-hosted.
@adapters [
# ── Also sends newsletters ──
%Adapter{
key: "smtp",
name: "SMTP",
module: Swoosh.Adapters.SMTP,
description: "Connect to any email server via SMTP. Works with most providers and hosts.",
tags: ["All email", "Any provider"],
key: "brevo",
name: "Brevo",
module: Swoosh.Adapters.Brevo,
description: "All-in-one platform, GDPR-friendly.",
tags: ["All email", "France", "GDPR"],
category: :all_email,
recommended: true,
free_tier: "300 emails/day free",
setup_hint: "Paste one API key",
url: "https://www.brevo.com",
fields: [
%Field{key: "relay", label: "Server host", type: :string, required: true},
%Field{key: "port", label: "Port", type: :integer, default: 587},
%Field{key: "username", label: "Username", type: :string},
%Field{key: "password", label: "Password", type: :secret}
%Field{key: "api_key", label: "API key", type: :secret, required: true}
]
},
%Adapter{
@@ -31,28 +32,23 @@ defmodule Berrypod.Mailer.Adapters do
module: Swoosh.Adapters.Sendgrid,
description: "Generous free tier, widely used.",
tags: ["All email", "US"],
category: :all_email,
free_tier: "100 emails/day free",
setup_hint: "Paste one API key",
url: "https://sendgrid.com",
fields: [
%Field{key: "api_key", label: "API key", type: :secret, required: true}
]
},
%Adapter{
key: "brevo",
name: "Brevo",
module: Swoosh.Adapters.Brevo,
description: "All-in-one platform, GDPR-friendly.",
tags: ["All email", "France", "GDPR"],
url: "https://www.brevo.com",
fields: [
%Field{key: "api_key", label: "API key", type: :secret, required: true}
]
},
%Adapter{
key: "mailjet",
name: "Mailjet",
module: Swoosh.Adapters.Mailjet,
description: "EU data processing, good free tier.",
tags: ["All email", "France", "GDPR"],
category: :all_email,
free_tier: "200 emails/day free",
setup_hint: "Two API keys needed",
url: "https://www.mailjet.com",
fields: [
%Field{key: "api_key", label: "API key", type: :secret, required: true},
@@ -65,62 +61,97 @@ defmodule Berrypod.Mailer.Adapters do
module: Swoosh.Adapters.MailerSend,
description: "Generous free tier, good analytics dashboard.",
tags: ["All email", "EU option"],
category: :all_email,
free_tier: "3,000 emails/month free",
setup_hint: "Paste one API key",
url: "https://www.mailersend.com",
fields: [
%Field{key: "api_key", label: "API key", type: :secret, required: true}
]
},
# ── Transactional only ──
%Adapter{
key: "resend",
name: "Resend",
module: Swoosh.Adapters.Resend,
description: "Developer-friendly API, simple setup.",
tags: ["Transactional", "US"],
category: :transactional,
free_tier: "3,000 emails/month free",
setup_hint: "Paste one API key",
url: "https://resend.com",
fields: [
%Field{key: "api_key", label: "API key", type: :secret, required: true}
]
},
%Adapter{
key: "postmark",
name: "Postmark",
module: Swoosh.Adapters.Postmark,
description: "Excellent deliverability tracking.",
tags: ["Transactional", "US"],
category: :transactional,
free_tier: "100 emails/month free",
setup_hint: "Paste one API key",
url: "https://postmarkapp.com",
fields: [
%Field{key: "api_key", label: "API key", type: :secret, required: true}
]
},
%Adapter{
key: "mailgun",
name: "Mailgun",
module: Swoosh.Adapters.Mailgun,
description: "EU region option available.",
tags: ["Transactional", "EU option", "Sweden"],
category: :transactional,
free_tier: "100 emails/day trial",
setup_hint: "API key + domain name",
url: "https://www.mailgun.com",
fields: [
%Field{key: "api_key", label: "API key", type: :secret, required: true},
%Field{key: "domain", label: "Domain", type: :string, required: true}
]
},
%Adapter{
key: "postmark",
name: "Postmark",
module: Swoosh.Adapters.Postmark,
description: "Excellent deliverability tracking.",
tags: ["Transactional", "US"],
url: "https://postmarkapp.com",
fields: [
%Field{key: "api_key", label: "API key", type: :secret, required: true}
]
},
%Adapter{
key: "mailpace",
name: "MailPace",
module: Swoosh.Adapters.MailPace,
description: "Privacy-focused, simple API.",
tags: ["Transactional", "UK"],
category: :transactional,
free_tier: "3,000 emails/month free",
setup_hint: "Paste one API key",
url: "https://mailpace.com",
fields: [
%Field{key: "api_key", label: "API key", type: :secret, required: true}
]
},
# ── Advanced ──
%Adapter{
key: "smtp",
name: "SMTP",
module: Swoosh.Adapters.SMTP,
description: "Connect to any email server via SMTP.",
tags: ["All email", "Any provider"],
category: :advanced,
setup_hint: "Server details needed",
fields: [
%Field{key: "relay", label: "Server host", type: :string, required: true},
%Field{key: "port", label: "Port", type: :integer, default: 587},
%Field{key: "username", label: "Username", type: :string},
%Field{key: "password", label: "Password", type: :secret}
]
},
%Adapter{
key: "postal",
name: "Postal",
module: Swoosh.Adapters.Postal,
description: "Full control over your email infrastructure.",
tags: ["All email", "Self-hosted", "Open source"],
category: :advanced,
free_tier: "Self-hosted, no limits",
setup_hint: "Requires your own server",
url: "https://docs.postalserver.io",
fields: [
%Field{key: "api_key", label: "API key", type: :secret, required: true},
@@ -132,6 +163,11 @@ defmodule Berrypod.Mailer.Adapters do
@doc "Returns all supported adapters."
def all, do: @adapters
@doc "Returns adapters grouped by category."
def grouped do
Enum.group_by(@adapters, & &1.category)
end
@doc "Returns an adapter by its string key, or nil."
def get(key) when is_binary(key) do
Enum.find(@adapters, &(&1.key == key))

View File

@@ -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"}
>
&nearr;
</.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} &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>
<%= 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 &rarr;
</.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

View File

@@ -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>