rework email settings for true progressive enhancement
All checks were successful
deploy / deploy (push) Successful in 1m19s

Render all adapter field sections in the form with CSS :has(:checked)
controlling visibility. Selecting a provider instantly shows its config
fields — no JS, no page reload, no server round-trip needed.

- Render all 6 adapter configs with data-adapter attribute
- CSS :has(:checked) show/hide rules per adapter in admin stylesheet
- Namespace field names per adapter (email[brevo][api_key] etc)
- Drop 4 transactional-only providers (Resend, Postmark, Mailgun, MailPace)
- Remove noscript "Switch provider" button and controller redirect workaround
- Remove configured_adapter hidden input tracking
- Hide JS-only test email button for no-JS users via noscript style
- LiveView progressively enhances with async save and test email

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-03-04 23:10:37 +00:00
parent dd20ea824f
commit db130a7155
12 changed files with 213 additions and 456 deletions

View File

@ -24,7 +24,7 @@ Tier 1 MVP complete. Tier 2 production readiness complete (except Litestream and
- Abandoned cart recovery (GDPR-compliant, single email)
- Favicon generation from source image (PNG variants, SVG dark mode, webmanifest)
- Complete SEO (OG/Twitter cards, JSON-LD, sitemap, canonical URLs, meta descriptions)
- Email settings admin with 10 adapter options and test email
- Email settings admin with 6 adapter options, no-JS support, and test email
- No-JS support across all key flows
- Fully Tailwind-free CSS (12 KB gzipped shop+theme, 95 KB gzipped admin total)
- CI pipeline (compile warnings, format, credo, dialyzer, tests)
@ -60,7 +60,7 @@ Based on usability testing (March 2026). Reworks the entire setup flow into a si
| A | Simplify initial setup to account creation only (email, password, shop name) | 1.5h | planned |
| B | Guided setup flow with progress bar (multi-step, skippable, explains "why") | 4h | planned |
| C | Forgiving API key validation (strip whitespace, format checks, helpful errors) | 1.5h | done |
| D | Email provider setup UX rework (recommended pick, grouping, guided flow, test email) | 2h | planned |
| D | Email provider setup UX rework (6 adapters, no-JS, `:has(:checked)` CSS, test email) | 2h | done |
| E | Contextual prompts for skipped steps (products, checkout, order detail) | 2h | done |
| F | Dashboard checklist and messaging rework | 2h | planned |
| G | Coming soon page fixes (logo layout, admin login link) | 30m | done |

View File

@ -1492,7 +1492,7 @@
outline-offset: 2px;
}
&.card-radio-card-selected {
&:has(:checked) {
border-color: var(--t-text-primary, #171717);
background: var(--t-surface-sunken, #e5e5e5);
}
@ -4379,6 +4379,20 @@
gap: 1.5rem;
}
/* Show only the adapter config matching the checked radio */
.admin-adapter-config[data-adapter] { display: none; }
.admin-content-medium:has(#email-adapter-brevo:checked) [data-adapter="brevo"],
.admin-content-medium:has(#email-adapter-sendgrid:checked) [data-adapter="sendgrid"],
.admin-content-medium:has(#email-adapter-mailjet:checked) [data-adapter="mailjet"],
.admin-content-medium:has(#email-adapter-mailersend:checked) [data-adapter="mailersend"],
.admin-content-medium:has(#email-adapter-smtp:checked) [data-adapter="smtp"],
.admin-content-medium:has(#email-adapter-postal:checked) [data-adapter="postal"] {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* ── Campaign form ── */
.admin-campaign-actions {

View File

@ -1,6 +1,6 @@
# Onboarding UX v2
Status: Planned
Status: In progress
Supersedes the original onboarding-ux plan. Based on usability testing session (March 2026) covering the setup wizard, launch checklist, email provider setup, and general onboarding flow.
@ -77,20 +77,31 @@ API key entry should be as forgiving as possible:
**Files:** `lib/berrypod_web/live/setup/onboarding.ex`, `lib/berrypod_web/live/admin/providers.ex`, validation logic in contexts
### D. Email provider setup UX
### D. Email provider setup UX — done
Rework the email provider selection and configuration:
Reworked the email provider selection and configuration. Changes across two sessions:
- **Recommended pick** highlighted at top — whichever has the best free tier, easiest setup, and supports newsletters. One clear "if you're not sure, pick this" option.
- **Two groups:** "Also sends newsletters" vs "Transactional only" with one-line explanation of the difference
- **One-liner per provider:** free tier info, setup difficulty ("paste one API key" vs "requires domain verification")
- **Self-hosted/SMTP** in a collapsible "Advanced" section at bottom
- **Guided flow after selection:** link to create account (new tab), then "Now enter these settings:" with config fields
- **Send test email** button after configuration
- **Remove** the masked key display ("Current: 84159e26•••4f3")
- **Default from address** to the admin email automatically — don't surface during setup, it's a general settings thing for later
**Session 1 (commit dd659e4):**
- Recommended pick (Brevo) highlighted with badge
- Two groups: "Also sends newsletters" vs "Transactional only"
- One-liner per provider with free tier info
- Self-hosted/SMTP in collapsible "Advanced" section
- Guided flow after selection with config fields
- Send test email button with async delivery
- Removed masked key display
- Default from address auto-set from admin email
- No-JS support via controller POST fallback
- 17 audit fixes (a11y, error handling, UX)
**Files:** `lib/berrypod_web/live/admin/email_settings.ex`, shared components for guided flow
**Session 2 (uncommitted):**
- Dropped 4 transactional-only providers (Resend, Postmark, Mailgun, MailPace) — all are transactional-only and not useful for a shop that needs newsletters. 6 adapters remain: Brevo, SendGrid, Mailjet, MailerSend + SMTP, Postal
- Removed "Popular providers" heading and description (unnecessary with fewer providers)
- Fixed no-JS provider switching: noscript submit button always visible with dynamic text
- Fixed no-JS radio highlight: replaced server-applied `card-radio-card-selected` class with pure CSS `:has(:checked)` selector
- Cleaned up key validation (removed 4 provider clauses, removed 2 known prefixes)
- Updated all related tests (adapters, key validation, email settings LiveView, controller, mailer)
**Files:** `lib/berrypod_web/live/admin/email_settings.ex`, `lib/berrypod/mailer/adapters.ex`, `lib/berrypod/key_validation.ex`, `assets/css/admin/components.css`, `lib/berrypod_web/controllers/email_settings_controller.ex`
### E. Contextual prompts for skipped steps
@ -148,7 +159,7 @@ Increase input field border contrast to meet WCAG AA (3:1 minimum for UI compone
| A | Simplify initial setup to account creation only | 1.5h | planned |
| B | Guided setup flow with progress bar | 4h | planned |
| C | Forgiving API key validation | 1.5h | planned |
| D | Email provider setup UX rework | 2h | planned |
| D | Email provider setup UX rework | 2h | done |
| E | Contextual prompts for skipped steps | 2h | done |
| F | Dashboard checklist and messaging rework | 2h | planned |
| G | Coming soon page fixes (logo + admin link) | 30m | planned |

View File

@ -84,8 +84,6 @@ defmodule Berrypod.KeyValidation do
@known_prefixes [
{"SG.", "SendGrid"},
{"xkeysib-", "Brevo"},
{"re_", "Resend"},
{"key-", "Mailgun"},
{"mlsn.", "MailerSend"}
]
@ -98,56 +96,6 @@ defmodule Berrypod.KeyValidation do
end
end
# Postmark: UUID format
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
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
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) or long RBAC keys
defp validate_email_format(key, "mailgun", "api_key") do
cond do
String.starts_with?(key, "key-") ->
{:ok, key}
String.length(key) >= 20 ->
{:ok, key}
wrong = wrong_provider_hint(key, "mailgun") ->
{:error, wrong}
true ->
{: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
cond do
@ -210,21 +158,6 @@ defmodule Berrypod.KeyValidation do
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) < 3 do

View File

@ -69,64 +69,6 @@ defmodule Berrypod.Mailer.Adapters do
%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: "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",

View File

@ -10,15 +10,11 @@ defmodule BerrypodWeb.EmailSettingsController do
alias Berrypod.Mailer
def update(conn, %{"email" => params}) do
selected = params["adapter"]
configured = params["configured_adapter"]
adapter_key = params["adapter"]
# Fields are namespaced: email[brevo][api_key] → params["brevo"]["api_key"]
adapter_params = params[adapter_key] || %{}
if selected != configured do
# User changed adapter radio but config fields are for the old adapter.
# Redirect to show the new adapter's config fields.
redirect(conn, to: ~p"/admin/settings/email?adapter=#{selected}")
else
case Mailer.save_config(selected, params, conn.assigns.current_scope.user.email) do
case Mailer.save_config(adapter_key, adapter_params, conn.assigns.current_scope.user.email) do
{:ok, _adapter_info} ->
conn
|> put_flash(:info, "Settings saved — send a test email to check it works")
@ -29,8 +25,7 @@ defmodule BerrypodWeb.EmailSettingsController do
conn
|> put_flash(:error, message)
|> redirect(to: ~p"/admin/settings/email?adapter=#{selected}")
end
|> redirect(to: ~p"/admin/settings/email")
end
end

View File

@ -8,21 +8,23 @@ defmodule BerrypodWeb.Admin.EmailSettings do
@impl true
def mount(_params, _session, socket) do
env_locked = Mailer.env_var_configured?()
{current_adapter, current_values} = Mailer.current_config()
{current_adapter, _current_values} = Mailer.current_config()
saved_adapter = Settings.get_setting("email_adapter")
adapter_key = current_adapter || saved_adapter
grouped = Adapters.grouped()
all_adapters = Adapters.all()
all_values = load_all_adapter_values()
{:ok,
socket
|> assign(:page_title, "Email settings")
|> assign(:env_locked, env_locked)
|> assign(:adapter_key, adapter_key)
|> assign(:current_values, current_values)
|> assign(:all_values, all_values)
|> assign(:all_email_adapters, grouped[:all_email] || [])
|> assign(:transactional_adapters, grouped[:transactional] || [])
|> assign(:advanced_adapters, grouped[:advanced] || [])
|> assign(:all_adapters, all_adapters)
|> assign(:email_configured, Mailer.email_configured?())
|> assign(:selected_adapter, adapter_key && Adapters.get(adapter_key))
|> assign(:sending_test, false)
@ -36,37 +38,14 @@ defmodule BerrypodWeb.Admin.EmailSettings do
@impl true
def handle_params(params, _uri, socket) do
# Support ?adapter=X for no-JS adapter switching
adapter_key = params["adapter"] || socket.assigns.adapter_key
socket =
if adapter_key && adapter_key != socket.assigns.adapter_key do
values = load_adapter_values(adapter_key)
socket
|> assign(:adapter_key, adapter_key)
|> assign(:selected_adapter, Adapters.get(adapter_key))
|> assign(:current_values, values)
|> assign(:field_errors, %{})
|> assign(:test_result, nil)
|> assign(:test_error, nil)
else
socket
end
{:noreply, assign(socket, :from_checklist, params["from"] == "checklist")}
end
defp load_adapter_values(nil), do: %{}
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)
defp load_all_adapter_values do
for adapter <- Adapters.all(), into: %{} do
values =
for field <- adapter.fields, into: %{} do
settings_key = Adapters.settings_key(adapter.key, field.key)
value =
case field.type do
@ -76,6 +55,8 @@ defmodule BerrypodWeb.Admin.EmailSettings do
{field.key, value}
end
{adapter.key, values}
end
end
@ -84,13 +65,10 @@ defmodule BerrypodWeb.Admin.EmailSettings do
if key == socket.assigns.adapter_key do
{:noreply, socket}
else
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)}
@ -102,16 +80,20 @@ defmodule BerrypodWeb.Admin.EmailSettings do
{:noreply, put_flash(socket, :error, "Email config is controlled by environment variables")}
else
adapter_key = params["adapter"]
# Fields are namespaced: email[brevo][api_key] → params["brevo"]["api_key"]
adapter_params = params[adapter_key] || %{}
case Mailer.save_config(adapter_key, params, socket.assigns.current_scope.user.email) do
case Mailer.save_config(
adapter_key,
adapter_params,
socket.assigns.current_scope.user.email
) do
{:ok, _adapter_info} ->
{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(:adapter_key, adapter_key)
|> assign(:selected_adapter, Adapters.get(adapter_key))
|> assign(:all_values, load_all_adapter_values())
|> assign(:email_configured, Mailer.email_configured?())
|> assign(:field_errors, %{})
|> assign(:test_result, nil)
@ -204,13 +186,6 @@ defmodule BerrypodWeb.Admin.EmailSettings do
phx-change="form_change"
phx-submit="save"
>
<%!-- Hidden field tracks which adapter's config fields are rendered --%>
<input
type="hidden"
name="email[configured_adapter]"
value={@adapter_key || ""}
/>
<%!-- Step 1: Choose a provider --%>
<div class="admin-setup-step">
<div class="admin-setup-step-header">
@ -221,16 +196,6 @@ defmodule BerrypodWeb.Admin.EmailSettings do
<fieldset class="card-radio-fieldset" disabled={@env_locked}>
<legend class="sr-only">Email provider</legend>
<h3 class="card-radio-group-heading">Popular providers</h3>
<p class="card-radio-group-desc">
Newsletters and transactional emails. All have free tiers.
<span
:if={Enum.any?(@all_email_adapters, & &1.recommended)}
class="card-radio-group-hint"
>
Not sure which? Pick the recommended one.
</span>
</p>
<div class="card-radio-grid">
<.provider_card
:for={adapter <- @all_email_adapters}
@ -240,20 +205,6 @@ defmodule BerrypodWeb.Admin.EmailSettings do
/>
</div>
<h3 class="card-radio-group-heading">Transactional only</h3>
<p class="card-radio-group-desc">
Order confirmations and shipping updates.
You'll need a separate service for newsletters later.
</p>
<div class="card-radio-grid">
<.provider_card
:for={adapter <- @transactional_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?
@ -270,60 +221,24 @@ defmodule BerrypodWeb.Admin.EmailSettings do
</fieldset>
</div>
<%!-- Steps 2 & 3 appear for the selected adapter only --%>
<div :if={@selected_adapter} class="admin-adapter-config">
<%!-- Step 2: Create an account (providers with sign-up URLs) --%>
<div :if={@selected_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={@selected_adapter.url} class="admin-link">
Sign up at {@selected_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 @selected_adapter.url, do: "3", else: "2"}
</span>
<h2 class="admin-setup-step-title">{adapter_fields_title(@selected_adapter)}</h2>
</div>
<p class="admin-setup-step-desc">{adapter_fields_instruction(@selected_adapter)}</p>
<%= for field <- @selected_adapter.fields do %>
<.adapter_field_static
field_def={field}
value={@current_values[field.key]}
disabled={@env_locked}
error={@field_errors[field.key]}
<%!-- Steps 2 & 3 for each adapter (CSS :has(:checked) shows the active one) --%>
<.adapter_config
:for={adapter <- @all_adapters}
adapter={adapter}
values={@all_values[adapter.key] || %{}}
field_errors={if(@adapter_key == adapter.key, do: @field_errors, else: %{})}
env_locked={@env_locked}
/>
<% end %>
<%= unless @env_locked do %>
<div class="admin-row admin-row-lg">
<.button phx-disable-with="Saving...">
Save settings
</.button>
</div>
<% end %>
</div>
</div>
<%!-- No-JS: if adapter changed but config not yet shown, submit to reload --%>
<noscript>
<div :if={!@selected_adapter} class="admin-row admin-row-lg" style="margin-top: 1rem;">
<.button>Continue</.button>
</div>
</noscript>
</.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
:if={@email_configured}
id="test-email-step"
class="admin-setup-step"
style="margin-top: 1.5rem;"
>
<div class="admin-setup-step-header">
<span class={[
"admin-setup-step-number",
@ -393,6 +308,8 @@ defmodule BerrypodWeb.Admin.EmailSettings do
Send a test to <strong>{@current_scope.user.email}</strong> to check everything works.
</p>
<div class="admin-row admin-row-sm">
<%!-- JS: async send via LiveView --%>
<span id="test-email-js">
<.button
type="button"
phx-click="send_test"
@ -401,15 +318,21 @@ defmodule BerrypodWeb.Admin.EmailSettings do
>
<.icon name="hero-paper-airplane" class="size-4" /> Send test email
</.button>
<%!-- No-JS fallback for test email --%>
</span>
<%!-- No-JS: form POST fallback (hides the JS button above) --%>
<noscript>
<style>
#test-email-js { display: none; }
</style>
<.form
for={%{}}
action={~p"/admin/settings/email/test"}
method="post"
style="display:inline"
>
<.button>Send test email</.button>
<.button>
<.icon name="hero-paper-airplane" class="size-4" /> Send test email
</.button>
</.form>
</noscript>
</div>
@ -422,6 +345,58 @@ defmodule BerrypodWeb.Admin.EmailSettings do
# ── Local components ──
attr :adapter, :map, required: true
attr :values, :map, required: true
attr :field_errors, :map, required: true
attr :env_locked, :boolean, required: true
defp adapter_config(assigns) do
~H"""
<div class="admin-adapter-config" data-adapter={@adapter.key}>
<%!-- 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>
<%!-- Paste your key / server details --%>
<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_input
adapter_key={@adapter.key}
field_def={field}
value={@values[field.key]}
disabled={@env_locked}
error={@field_errors[field.key]}
/>
<% end %>
<%= unless @env_locked do %>
<div class="admin-row admin-row-lg">
<.button phx-disable-with="Saving...">
Save settings
</.button>
</div>
<% end %>
</div>
</div>
"""
end
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"
@ -448,10 +423,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do
defp provider_card(assigns) do
~H"""
<label class={[
"card-radio-card",
@selected == @adapter.key && "card-radio-card-selected"
]}>
<label class="card-radio-card">
<input
type="radio"
id={"email-adapter-#{@adapter.key}"}
@ -474,18 +446,20 @@ defmodule BerrypodWeb.Admin.EmailSettings do
end
# ── Field renderers ──
# Fields are namespaced per adapter: email[brevo][api_key], email[smtp][relay], etc.
attr :adapter_key, :string, required: true
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
defp adapter_field_input(%{field_def: %{type: :secret}} = assigns) do
assigns = assign(assigns, :errors, if(assigns.error, do: [assigns.error], else: []))
~H"""
<.input
name={"email[#{@field_def.key}]"}
name={"email[#{@adapter_key}][#{@field_def.key}]"}
value=""
type="text"
label={@field_def.label}
@ -497,12 +471,12 @@ defmodule BerrypodWeb.Admin.EmailSettings do
"""
end
defp adapter_field_static(%{field_def: %{type: :integer}} = assigns) do
defp adapter_field_input(%{field_def: %{type: :integer}} = assigns) do
assigns = assign(assigns, :errors, if(assigns.error, do: [assigns.error], else: []))
~H"""
<.input
name={"email[#{@field_def.key}]"}
name={"email[#{@adapter_key}][#{@field_def.key}]"}
value={@value || @field_def.default || ""}
type="number"
label={@field_def.label}
@ -512,12 +486,12 @@ defmodule BerrypodWeb.Admin.EmailSettings do
"""
end
defp adapter_field_static(assigns) do
defp adapter_field_input(assigns) do
assigns = assign(assigns, :errors, if(assigns.error, do: [assigns.error], else: []))
~H"""
<.input
name={"email[#{@field_def.key}]"}
name={"email[#{@adapter_key}][#{@field_def.key}]"}
value={@value || @field_def.default || ""}
type="text"
label={@field_def.label}

View File

@ -112,52 +112,6 @@ defmodule Berrypod.KeyValidationTest do
end
end
describe "validate_email_key/3 - Postmark" do
test "accepts valid UUID" do
key = "abc12345-abcd-1234-abcd-123456789abc"
assert {:ok, ^key} = KeyValidation.validate_email_key(key, "postmark", "api_key")
end
test "accepts uppercase UUID" do
key = "ABC12345-ABCD-1234-ABCD-123456789ABC"
assert {:ok, ^key} = KeyValidation.validate_email_key(key, "postmark", "api_key")
end
test "rejects non-UUID" do
assert {:error, msg} = KeyValidation.validate_email_key("not-a-uuid", "postmark", "api_key")
assert msg =~ "UUID"
end
end
describe "validate_email_key/3 - Resend" do
test "accepts valid Resend key" do
assert {:ok, "re_abc123xyz"} =
KeyValidation.validate_email_key("re_abc123xyz", "resend", "api_key")
end
test "rejects key without re_ prefix" do
assert {:error, msg} = KeyValidation.validate_email_key("not_resend", "resend", "api_key")
assert msg =~ "re_"
end
end
describe "validate_email_key/3 - Mailgun" do
test "accepts classic key- format" do
key = "key-abcdef1234567890abcdef1234567890"
assert {:ok, ^key} = KeyValidation.validate_email_key(key, "mailgun", "api_key")
end
test "accepts newer long key without prefix" do
key = "abcdef1234567890abcdef1234567890abcdef1234"
assert {:ok, ^key} = KeyValidation.validate_email_key(key, "mailgun", "api_key")
end
test "rejects short key without prefix" do
assert {:error, msg} = KeyValidation.validate_email_key("short", "mailgun", "api_key")
assert msg =~ "key-"
end
end
describe "validate_email_key/3 - Brevo" do
test "accepts valid Brevo key" do
key = "xkeysib-" <> String.duplicate("ab", 32)
@ -211,19 +165,6 @@ defmodule Berrypod.KeyValidationTest do
end
end
describe "validate_email_key/3 - MailPace" do
test "accepts valid token" do
key = "abc123def456ghi789"
assert {:ok, ^key} = KeyValidation.validate_email_key(key, "mailpace", "api_key")
end
test "rejects short token" do
assert {:error, msg} = KeyValidation.validate_email_key("short", "mailpace", "api_key")
assert msg =~ "too short"
assert msg =~ "MailPace"
end
end
describe "validate_email_key/3 - cross-provider detection" do
test "detects Brevo key pasted into SendGrid" do
key = "xkeysib-abc123def456"
@ -237,25 +178,14 @@ defmodule Berrypod.KeyValidationTest do
assert msg =~ "SendGrid key"
end
test "detects Resend key pasted into Postmark" do
key = "re_abc123xyz456"
assert {:error, msg} = KeyValidation.validate_email_key(key, "postmark", "api_key")
assert msg =~ "Resend key"
end
test "detects MailerSend key pasted into Mailgun" do
test "detects MailerSend key pasted into Brevo" do
key = "mlsn.abc123"
assert {:error, msg} = KeyValidation.validate_email_key(key, "mailgun", "api_key")
assert {:error, msg} = KeyValidation.validate_email_key(key, "brevo", "api_key")
assert msg =~ "MailerSend key"
end
end
describe "validate_email_key/3 - non-api_key fields" do
test "accepts reasonable value for domain" do
assert {:ok, "mg.example.com"} =
KeyValidation.validate_email_key("mg.example.com", "mailgun", "domain")
end
test "accepts reasonable value for relay" do
assert {:ok, "smtp.example.com"} =
KeyValidation.validate_email_key("smtp.example.com", "smtp", "relay")

View File

@ -7,7 +7,7 @@ defmodule Berrypod.Mailer.AdaptersTest do
test "returns a list of adapters" do
adapters = Adapters.all()
assert is_list(adapters)
assert length(adapters) >= 9
assert length(adapters) >= 5
end
test "each adapter has required keys" do
@ -43,10 +43,10 @@ defmodule Berrypod.Mailer.AdaptersTest do
describe "get/1" do
test "returns adapter by key" do
assert %{key: "smtp", name: "SMTP"} = Adapters.get("smtp")
assert %{key: "postmark", name: "Postmark"} = Adapters.get("postmark")
assert %{key: "mailjet", name: "Mailjet"} = Adapters.get("mailjet")
assert %{key: "mailpace", name: "MailPace"} = Adapters.get("mailpace")
assert %{key: "postal", name: "Postal"} = Adapters.get("postal")
assert is_nil(Adapters.get("postmark"))
assert is_nil(Adapters.get("mailpace"))
end
test "returns nil for unknown key" do
@ -70,7 +70,6 @@ defmodule Berrypod.Mailer.AdaptersTest do
test "returns unique keys from all adapters" do
keys = Adapters.all_field_keys()
assert is_list(keys)
assert "email_postmark_api_key" in keys
assert "email_smtp_relay" in keys
assert length(keys) == length(Enum.uniq(keys))
end
@ -78,7 +77,7 @@ defmodule Berrypod.Mailer.AdaptersTest do
describe "settings_key/2" do
test "namespaces key with adapter" do
assert Adapters.settings_key("postmark", "api_key") == "email_postmark_api_key"
assert Adapters.settings_key("brevo", "api_key") == "email_brevo_api_key"
assert Adapters.settings_key("smtp", "relay") == "email_smtp_relay"
end
end

View File

@ -25,14 +25,14 @@ defmodule Berrypod.MailerTest do
describe "load_config/0" do
test "loads adapter config from settings" do
Settings.put_setting("email_adapter", "postmark")
Settings.put_secret("email_postmark_api_key", "pm_test_key_123")
Settings.put_setting("email_adapter", "brevo")
Settings.put_secret("email_brevo_api_key", "xkeysib-test-key-123")
Mailer.load_config()
config = Application.get_env(:berrypod, Mailer)
assert config[:adapter] == Swoosh.Adapters.Postmark
assert config[:api_key] == "pm_test_key_123"
assert config[:adapter] == Swoosh.Adapters.Brevo
assert config[:api_key] == "xkeysib-test-key-123"
end
test "loads SMTP config with multiple fields" do
@ -283,13 +283,13 @@ defmodule Berrypod.MailerTest do
end
test "returns adapter key and config when configured from settings" do
Settings.put_setting("email_adapter", "postmark")
Settings.put_secret("email_postmark_api_key", "pm_test_key_123")
Settings.put_setting("email_adapter", "brevo")
Settings.put_secret("email_brevo_api_key", "xkeysib-test-key-123")
Mailer.load_config()
{adapter_key, config} = Mailer.current_config()
assert adapter_key == "postmark"
assert adapter_key == "brevo"
assert config["api_key"] =~ "•••"
end
end

View File

@ -24,8 +24,7 @@ defmodule BerrypodWeb.EmailSettingsControllerTest do
post(conn, ~p"/admin/settings/email", %{
email: %{
adapter: "brevo",
configured_adapter: "brevo",
api_key: "xkeysib-abc123def456"
brevo: %{api_key: "xkeysib-abc123def456"}
}
})
@ -34,19 +33,10 @@ defmodule BerrypodWeb.EmailSettingsControllerTest do
assert Settings.get_setting("email_adapter") == "brevo"
end
test "redirects with adapter param when adapter changed", %{conn: conn} do
conn =
post(conn, ~p"/admin/settings/email", %{
email: %{adapter: "resend", configured_adapter: "brevo", api_key: ""}
})
assert redirected_to(conn) == ~p"/admin/settings/email?adapter=resend"
end
test "redirects with error on validation failure", %{conn: conn} do
conn =
post(conn, ~p"/admin/settings/email", %{
email: %{adapter: "brevo", configured_adapter: "brevo", api_key: ""}
email: %{adapter: "brevo", brevo: %{api_key: ""}}
})
assert redirected_to(conn) =~ ~p"/admin/settings/email"
@ -56,8 +46,8 @@ defmodule BerrypodWeb.EmailSettingsControllerTest do
describe "POST /admin/settings/email/test" do
test "sends test email and redirects", %{conn: conn} do
Settings.put_setting("email_adapter", "postmark")
Settings.put_secret("email_postmark_api_key", "pm_test_abc")
Settings.put_setting("email_adapter", "brevo")
Settings.put_secret("email_brevo_api_key", "xkeysib-test-abc")
conn = post(conn, ~p"/admin/settings/email/test")

View File

@ -33,27 +33,17 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
# Provider names rendered as radio cards
assert html =~ "Brevo"
assert html =~ "Mailjet"
assert html =~ "MailPace"
assert html =~ "Postal"
end
test "shows all three provider groups", %{conn: conn} do
test "shows providers and advanced section", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/settings/email")
# All-email providers
assert html =~ "Popular providers"
assert html =~ "Brevo"
assert html =~ "SendGrid"
assert html =~ "Mailjet"
assert html =~ "MailerSend"
# Transactional providers
assert html =~ "Transactional only"
assert html =~ "Resend"
assert html =~ "Postmark"
assert html =~ "Mailgun"
assert html =~ "MailPace"
# Advanced in details
assert html =~ "Already have your own email server?"
assert html =~ "SMTP"
@ -64,10 +54,20 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
{:ok, _view, html} = live(conn, ~p"/admin/settings/email")
assert html =~ "needs an email provider"
assert html =~ "300 emails/day free"
end
test "selecting a provider shows its config fields", %{conn: conn} do
test "renders all adapter field sections in the form", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/settings/email")
# All adapters have their config sections rendered (CSS controls visibility)
assert html =~ ~s(data-adapter="brevo")
assert html =~ ~s(data-adapter="sendgrid")
assert html =~ ~s(data-adapter="mailjet")
assert html =~ ~s(data-adapter="smtp")
assert html =~ ~s(data-adapter="postal")
end
test "selecting a provider updates via phx-change", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
# Select SMTP via form change (radio inputs fire phx-change)
@ -76,35 +76,9 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "smtp"}})
|> render_change()
# SMTP fields are always rendered; check the step title changes
assert html =~ "Server host"
assert html =~ "Port"
assert html =~ "Username"
assert html =~ "Password"
end
test "selecting a different provider shows different fields", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
# Select Brevo which needs just an api_key
html =
view
|> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "brevo"}})
|> render_change()
assert html =~ "API key"
assert html =~ "Brevo"
end
test "selecting a transactional provider shows its config", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
html =
view
|> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "resend"}})
|> render_change()
assert html =~ "API key"
assert html =~ "Resend"
end
test "saving config persists settings", %{conn: conn} do
@ -115,11 +89,11 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "brevo"}})
|> render_change()
# Submit with an API key
# Submit with namespaced fields: email[brevo][api_key]
html =
view
|> form("form[phx-submit=\"save\"]", %{
email: %{adapter: "brevo", api_key: "xkeysib-abc123def456"}
email: %{adapter: "brevo", brevo: %{api_key: "xkeysib-abc123def456"}}
})
|> render_submit()
@ -138,7 +112,9 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
# Submit without API key
html =
view
|> form("form[phx-submit=\"save\"]", %{email: %{adapter: "brevo", api_key: ""}})
|> form("form[phx-submit=\"save\"]", %{
email: %{adapter: "brevo", brevo: %{api_key: ""}}
})
|> render_submit()
assert html =~ "API key is required"
@ -154,7 +130,7 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
html =
view
|> form("form[phx-submit=\"save\"]", %{
email: %{adapter: "smtp", relay: "smtp.example.com", port: "abc"}
email: %{adapter: "smtp", smtp: %{relay: "smtp.example.com", port: "abc"}}
})
|> render_submit()
@ -162,8 +138,8 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
end
test "shows test email section when configured", %{conn: conn} do
Settings.put_setting("email_adapter", "postmark")
Settings.put_secret("email_postmark_api_key", "pm_test_abc")
Settings.put_setting("email_adapter", "brevo")
Settings.put_secret("email_brevo_api_key", "xkeysib-test-abc")
{:ok, _view, html} = live(conn, ~p"/admin/settings/email")
@ -182,8 +158,8 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
end
test "sending test email shows success and sets verified flag", %{conn: conn} do
Settings.put_setting("email_adapter", "postmark")
Settings.put_secret("email_postmark_api_key", "pm_test_abc")
Settings.put_setting("email_adapter", "brevo")
Settings.put_secret("email_brevo_api_key", "xkeysib-test-abc")
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
@ -205,14 +181,14 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
# Switch to Brevo and save
# Switch to Brevo and save (namespaced fields)
view
|> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "brevo"}})
|> render_change()
view
|> form("form[phx-submit=\"save\"]", %{
email: %{adapter: "brevo", api_key: "xkeysib-switch-test"}
email: %{adapter: "brevo", brevo: %{api_key: "xkeysib-switch-test"}}
})
|> render_submit()
@ -236,7 +212,7 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
view
|> form("form[phx-submit=\"save\"]", %{
email: %{adapter: "brevo", api_key: "xkeysib-def789ghi012"}
email: %{adapter: "brevo", brevo: %{api_key: "xkeysib-def789ghi012"}}
})
|> render_submit()
@ -262,13 +238,6 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
assert html =~ "API key"
end
test "adapter query param preselects provider", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/settings/email?adapter=resend")
assert html =~ "Resend"
assert html =~ "API key"
end
test "from_checklist param shows checklist banner", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/settings/email?from=checklist")