fix email settings: missing providers, a11y, no-JS support
show all 10 providers in three groups (popular, transactional, advanced) with category headings. fix phx-change clobbering text fields, async test email sending state, integer parse crash on bad port. add keyboard focus on card radios, fieldset legend, WCAG-compliant badge contrast, responsive grid. extract shared save_config into Mailer, add no-JS controller fallback with configured_adapter hidden field for adapter change detection. remove CardRadioScroll JS hook (no longer needed). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
dd659e4c61
commit
dd20ea824f
@ -1459,6 +1459,10 @@
|
|||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
|
|
||||||
|
@media (max-width: 30rem) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-radio-card {
|
.card-radio-card {
|
||||||
@ -1483,6 +1487,11 @@
|
|||||||
background: color-mix(in oklch, var(--t-surface-sunken) 50%, transparent);
|
background: color-mix(in oklch, var(--t-surface-sunken) 50%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:has(:focus-visible) {
|
||||||
|
outline: 2px solid var(--t-accent, oklch(0.55 0.2 250));
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
&.card-radio-card-selected {
|
&.card-radio-card-selected {
|
||||||
border-color: var(--t-text-primary, #171717);
|
border-color: var(--t-text-primary, #171717);
|
||||||
background: var(--t-surface-sunken, #e5e5e5);
|
background: var(--t-surface-sunken, #e5e5e5);
|
||||||
@ -4272,7 +4281,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-radio-recommended {
|
.card-radio-recommended {
|
||||||
background: var(--admin-accent, oklch(0.65 0.2 145));
|
background: oklch(0.45 0.15 145);
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@ -4338,6 +4347,29 @@
|
|||||||
color: var(--t-status-error, oklch(0.6 0.2 25));
|
color: var(--t-status-error, oklch(0.6 0.2 25));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Provider group headings ── */
|
||||||
|
|
||||||
|
.card-radio-group-heading {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--admin-text-primary);
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-radio-group-heading:first-of-type {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-radio-group-desc {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
margin: 0.125rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-radio-group-hint {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Email adapter config ── */
|
/* ── Email adapter config ── */
|
||||||
|
|
||||||
.admin-adapter-config {
|
.admin-adapter-config {
|
||||||
@ -4345,10 +4377,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
|
|
||||||
&[hidden] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Campaign form ── */
|
/* ── Campaign form ── */
|
||||||
|
|||||||
@ -507,28 +507,6 @@ const CollectionFilters = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const CardRadioScroll = {
|
|
||||||
mounted() {
|
|
||||||
this.el.addEventListener("change", (e) => {
|
|
||||||
if (!e.target.matches('input[type="radio"]')) return
|
|
||||||
const key = e.target.value
|
|
||||||
const form = this.el.closest("form")
|
|
||||||
if (!form) return
|
|
||||||
|
|
||||||
form.querySelectorAll("[data-adapter]").forEach((section) => {
|
|
||||||
const match = section.dataset.adapter === key
|
|
||||||
section.hidden = !match
|
|
||||||
section.querySelectorAll("input, textarea, select, button[type='submit']").forEach((input) => {
|
|
||||||
input.disabled = !match
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const target = document.getElementById(`adapter-config-${key}`)
|
|
||||||
if (target) target.scrollIntoView({ behavior: "smooth", block: "nearest" })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Analytics export: reads the current period and filters from the DOM at click time
|
// Analytics export: reads the current period and filters from the DOM at click time
|
||||||
// so the download URL is always correct, even if clicked before the LiveView re-render.
|
// so the download URL is always correct, even if clicked before the LiveView re-render.
|
||||||
const AnalyticsExport = {
|
const AnalyticsExport = {
|
||||||
@ -704,7 +682,7 @@ const EditorKeyboard = {
|
|||||||
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||||
const liveSocket = new LiveSocket("/live", Socket, {
|
const liveSocket = new LiveSocket("/live", Socket, {
|
||||||
params: {_csrf_token: csrfToken, screen_width: window.innerWidth},
|
params: {_csrf_token: csrfToken, screen_width: window.innerWidth},
|
||||||
hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, CollectionFilters, CardRadioScroll, AnalyticsInit, AnalyticsExport, ChartTooltip, DirtyGuard, EditorKeyboard},
|
hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, CollectionFilters, AnalyticsInit, AnalyticsExport, ChartTooltip, DirtyGuard, EditorKeyboard},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Show progress bar on live navigation and form submits
|
// Show progress bar on live navigation and form submits
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
defmodule Berrypod.Mailer do
|
defmodule Berrypod.Mailer do
|
||||||
use Swoosh.Mailer, otp_app: :berrypod
|
use Swoosh.Mailer, otp_app: :berrypod
|
||||||
|
|
||||||
|
alias Berrypod.KeyValidation
|
||||||
alias Berrypod.Mailer.Adapters
|
alias Berrypod.Mailer.Adapters
|
||||||
alias Berrypod.Settings
|
alias Berrypod.Settings
|
||||||
|
|
||||||
@ -132,6 +133,117 @@ defmodule Berrypod.Mailer do
|
|||||||
Settings.get_setting("email_from_address") || "noreply@#{default_from_domain()}"
|
Settings.get_setting("email_from_address") || "noreply@#{default_from_domain()}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Validates and persists email adapter configuration.
|
||||||
|
|
||||||
|
Trims values, validates required fields and key formats, clears settings
|
||||||
|
from other providers, and applies config immediately.
|
||||||
|
|
||||||
|
Returns `{:ok, adapter_info}` on success or `{:error, field_errors}`
|
||||||
|
where field_errors is a map of field_key => error_message.
|
||||||
|
"""
|
||||||
|
def save_config(adapter_key, params, fallback_from_email) do
|
||||||
|
case Adapters.get(adapter_key) do
|
||||||
|
nil ->
|
||||||
|
{:error, %{"_base" => "Please select an email provider"}}
|
||||||
|
|
||||||
|
adapter_info ->
|
||||||
|
params = trim_params(params)
|
||||||
|
field_errors = validate_adapter_fields(adapter_info, params)
|
||||||
|
|
||||||
|
if field_errors == %{} do
|
||||||
|
persist_adapter_config(adapter_info, params, fallback_from_email)
|
||||||
|
{:ok, adapter_info}
|
||||||
|
else
|
||||||
|
{:error, field_errors}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp trim_params(params) do
|
||||||
|
Map.new(params, fn {k, v} -> {k, if(is_binary(v), do: String.trim(v), else: v)} end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_adapter_fields(adapter_info, params) do
|
||||||
|
missing_errors =
|
||||||
|
for field <- adapter_info.fields,
|
||||||
|
field.required,
|
||||||
|
val = params[field.key],
|
||||||
|
is_nil(val) or val == "",
|
||||||
|
# Secret fields can be left blank to keep existing value
|
||||||
|
not (field.type == :secret and
|
||||||
|
Settings.get_secret(Adapters.settings_key(adapter_info.key, field.key)) != nil),
|
||||||
|
into: %{} do
|
||||||
|
{field.key, "#{field.label} is required"}
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
integer_errors =
|
||||||
|
for field <- adapter_info.fields,
|
||||||
|
field.type == :integer,
|
||||||
|
value = params[field.key],
|
||||||
|
is_binary(value) and value != "",
|
||||||
|
match?(:error, Integer.parse(value)),
|
||||||
|
into: %{} do
|
||||||
|
{field.key, "#{field.label} must be a number"}
|
||||||
|
end
|
||||||
|
|
||||||
|
missing_errors |> Map.merge(format_errors) |> Map.merge(integer_errors)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp persist_adapter_config(adapter_info, params, fallback_from_email) do
|
||||||
|
# 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 field values (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 configured
|
||||||
|
if Settings.get_setting("email_from_address") in [nil, ""] do
|
||||||
|
Settings.put_setting("email_from_address", fallback_from_email)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Require re-verification and apply immediately
|
||||||
|
clear_email_verified()
|
||||||
|
load_config()
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Turns a raw delivery error into a user-friendly message.
|
Turns a raw delivery error into a user-friendly message.
|
||||||
|
|
||||||
|
|||||||
54
lib/berrypod_web/controllers/email_settings_controller.ex
Normal file
54
lib/berrypod_web/controllers/email_settings_controller.ex
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
defmodule BerrypodWeb.EmailSettingsController do
|
||||||
|
@moduledoc """
|
||||||
|
No-JS fallback for email settings form submission.
|
||||||
|
|
||||||
|
With JS enabled, the LiveView handles everything. Without JS,
|
||||||
|
the form POSTs here and we redirect back to the LiveView page.
|
||||||
|
"""
|
||||||
|
use BerrypodWeb, :controller
|
||||||
|
|
||||||
|
alias Berrypod.Mailer
|
||||||
|
|
||||||
|
def update(conn, %{"email" => params}) do
|
||||||
|
selected = params["adapter"]
|
||||||
|
configured = params["configured_adapter"]
|
||||||
|
|
||||||
|
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
|
||||||
|
{:ok, _adapter_info} ->
|
||||||
|
conn
|
||||||
|
|> put_flash(:info, "Settings saved — send a test email to check it works")
|
||||||
|
|> redirect(to: ~p"/admin/settings/email")
|
||||||
|
|
||||||
|
{:error, field_errors} when is_map(field_errors) ->
|
||||||
|
message = field_errors |> Map.values() |> Enum.join(". ")
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, message)
|
||||||
|
|> redirect(to: ~p"/admin/settings/email?adapter=#{selected}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test(conn, _params) do
|
||||||
|
user = conn.assigns.current_scope.user
|
||||||
|
|
||||||
|
case Mailer.send_test_email(user.email, Mailer.from_address()) do
|
||||||
|
{:ok, _} ->
|
||||||
|
Mailer.mark_email_verified()
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_flash(:info, "Test email sent to #{user.email}")
|
||||||
|
|> redirect(to: ~p"/admin/settings/email")
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, Mailer.friendly_error(reason))
|
||||||
|
|> redirect(to: ~p"/admin/settings/email")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -1,7 +1,6 @@
|
|||||||
defmodule BerrypodWeb.Admin.EmailSettings do
|
defmodule BerrypodWeb.Admin.EmailSettings do
|
||||||
use BerrypodWeb, :live_view
|
use BerrypodWeb, :live_view
|
||||||
|
|
||||||
alias Berrypod.KeyValidation
|
|
||||||
alias Berrypod.Mailer
|
alias Berrypod.Mailer
|
||||||
alias Berrypod.Mailer.Adapters
|
alias Berrypod.Mailer.Adapters
|
||||||
alias Berrypod.Settings
|
alias Berrypod.Settings
|
||||||
@ -21,8 +20,8 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
|> assign(:env_locked, env_locked)
|
|> assign(:env_locked, env_locked)
|
||||||
|> assign(:adapter_key, adapter_key)
|
|> assign(:adapter_key, adapter_key)
|
||||||
|> assign(:current_values, current_values)
|
|> assign(:current_values, current_values)
|
||||||
|> assign(:all_adapters, Adapters.all())
|
|> assign(:all_email_adapters, grouped[:all_email] || [])
|
||||||
|> assign(:recommended_adapters, grouped[:all_email] || [])
|
|> assign(:transactional_adapters, grouped[:transactional] || [])
|
||||||
|> assign(:advanced_adapters, grouped[:advanced] || [])
|
|> assign(:advanced_adapters, grouped[:advanced] || [])
|
||||||
|> assign(:email_configured, Mailer.email_configured?())
|
|> assign(:email_configured, Mailer.email_configured?())
|
||||||
|> assign(:selected_adapter, adapter_key && Adapters.get(adapter_key))
|
|> assign(:selected_adapter, adapter_key && Adapters.get(adapter_key))
|
||||||
@ -37,9 +36,29 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_params(params, _uri, socket) do
|
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")}
|
{:noreply, assign(socket, :from_checklist, params["from"] == "checklist")}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp load_adapter_values(nil), do: %{}
|
||||||
|
|
||||||
defp load_adapter_values(adapter_key) do
|
defp load_adapter_values(adapter_key) do
|
||||||
case Adapters.get(adapter_key) do
|
case Adapters.get(adapter_key) do
|
||||||
nil ->
|
nil ->
|
||||||
@ -61,7 +80,10 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("change_adapter", %{"email" => %{"adapter" => key}}, socket) do
|
def handle_event("form_change", %{"email" => %{"adapter" => key}}, socket) do
|
||||||
|
if key == socket.assigns.adapter_key do
|
||||||
|
{:noreply, socket}
|
||||||
|
else
|
||||||
values = load_adapter_values(key)
|
values = load_adapter_values(key)
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
@ -73,26 +95,43 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
|> assign(:test_result, nil)
|
|> assign(:test_result, nil)
|
||||||
|> assign(:test_error, nil)}
|
|> assign(:test_error, nil)}
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("save", %{"email" => params}, socket) do
|
def handle_event("save", %{"email" => params}, socket) do
|
||||||
if socket.assigns.env_locked do
|
if socket.assigns.env_locked do
|
||||||
{:noreply, put_flash(socket, :error, "Email config is controlled by environment variables")}
|
{:noreply, put_flash(socket, :error, "Email config is controlled by environment variables")}
|
||||||
else
|
else
|
||||||
adapter_key = params["adapter"]
|
adapter_key = params["adapter"]
|
||||||
adapter_info = Adapters.get(adapter_key)
|
|
||||||
|
|
||||||
if adapter_info do
|
case Mailer.save_config(adapter_key, params, socket.assigns.current_scope.user.email) do
|
||||||
save_adapter_config(socket, adapter_info, params)
|
{:ok, _adapter_info} ->
|
||||||
else
|
{current_adapter, current_values} = Mailer.current_config()
|
||||||
{:noreply, put_flash(socket, :error, "Please select an email provider")}
|
|
||||||
|
{: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")}
|
||||||
|
|
||||||
|
{:error, field_errors} when is_map(field_errors) ->
|
||||||
|
{:noreply, assign(socket, :field_errors, field_errors)}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("send_test", _params, socket) do
|
def handle_event("send_test", _params, socket) do
|
||||||
user = socket.assigns.current_scope.user
|
send(self(), :do_send_test)
|
||||||
|
{:noreply, assign(socket, :sending_test, true)}
|
||||||
|
end
|
||||||
|
|
||||||
socket = assign(socket, :sending_test, true)
|
@impl true
|
||||||
|
def handle_info(:do_send_test, socket) do
|
||||||
|
user = socket.assigns.current_scope.user
|
||||||
|
|
||||||
case Mailer.send_test_email(user.email, Mailer.from_address()) do
|
case Mailer.send_test_email(user.email, Mailer.from_address()) do
|
||||||
{:ok, _} ->
|
{:ok, _} ->
|
||||||
@ -114,103 +153,8 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp save_adapter_config(socket, adapter_info, params) do
|
# Swoosh test adapter sends {:email, ...} messages — ignore them
|
||||||
# Trim all values
|
def handle_info({:email, _}, socket), do: {:noreply, socket}
|
||||||
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
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
@ -253,21 +197,57 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<section class="admin-section">
|
<section class="admin-section">
|
||||||
<.form for={@form} phx-change="change_adapter" phx-submit="save">
|
<.form
|
||||||
|
for={@form}
|
||||||
|
action={~p"/admin/settings/email"}
|
||||||
|
method="post"
|
||||||
|
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 --%>
|
<%!-- Step 1: Choose a provider --%>
|
||||||
<div class="admin-setup-step">
|
<div class="admin-setup-step">
|
||||||
<div class="admin-setup-step-header">
|
<div class="admin-setup-step-header">
|
||||||
<span class="admin-setup-step-number">1</span>
|
<span class="admin-setup-step-number">1</span>
|
||||||
<h2 class="admin-setup-step-title">Choose a provider</h2>
|
<h2 class="admin-setup-step-title">Choose a provider</h2>
|
||||||
</div>
|
</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}>
|
<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">
|
<div class="card-radio-grid">
|
||||||
<.provider_card
|
<.provider_card
|
||||||
:for={adapter <- @recommended_adapters}
|
:for={adapter <- @all_email_adapters}
|
||||||
|
adapter={adapter}
|
||||||
|
selected={@adapter_key}
|
||||||
|
disabled={@env_locked}
|
||||||
|
/>
|
||||||
|
</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}
|
adapter={adapter}
|
||||||
selected={@adapter_key}
|
selected={@adapter_key}
|
||||||
disabled={@env_locked}
|
disabled={@env_locked}
|
||||||
@ -289,26 +269,18 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
</details>
|
</details>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Steps 2 & 3 appear per-adapter after selection --%>
|
<%!-- Steps 2 & 3 appear for the selected adapter only --%>
|
||||||
<%= for adapter <- @all_adapters do %>
|
<div :if={@selected_adapter} class="admin-adapter-config">
|
||||||
<% 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) --%>
|
<%!-- Step 2: Create an account (providers with sign-up URLs) --%>
|
||||||
<div :if={adapter.url} class="admin-setup-step">
|
<div :if={@selected_adapter.url} class="admin-setup-step">
|
||||||
<div class="admin-setup-step-header">
|
<div class="admin-setup-step-header">
|
||||||
<span class="admin-setup-step-number">2</span>
|
<span class="admin-setup-step-number">2</span>
|
||||||
<h2 class="admin-setup-step-title">Create a free account</h2>
|
<h2 class="admin-setup-step-title">Create a free account</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="admin-setup-step-desc">
|
<p class="admin-setup-step-desc">
|
||||||
<.external_link href={adapter.url} class="admin-link">
|
<.external_link href={@selected_adapter.url} class="admin-link">
|
||||||
Sign up at {adapter.name} ↗
|
Sign up at {@selected_adapter.name} ↗
|
||||||
</.external_link>
|
</.external_link>
|
||||||
if you don't already have an account. It's free.
|
if you don't already have an account. It's free.
|
||||||
</p>
|
</p>
|
||||||
@ -318,29 +290,35 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
<div class="admin-setup-step">
|
<div class="admin-setup-step">
|
||||||
<div class="admin-setup-step-header">
|
<div class="admin-setup-step-header">
|
||||||
<span class="admin-setup-step-number">
|
<span class="admin-setup-step-number">
|
||||||
{if adapter.url, do: "3", else: "2"}
|
{if @selected_adapter.url, do: "3", else: "2"}
|
||||||
</span>
|
</span>
|
||||||
<h2 class="admin-setup-step-title">{adapter_fields_title(adapter)}</h2>
|
<h2 class="admin-setup-step-title">{adapter_fields_title(@selected_adapter)}</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="admin-setup-step-desc">{adapter_fields_instruction(adapter)}</p>
|
<p class="admin-setup-step-desc">{adapter_fields_instruction(@selected_adapter)}</p>
|
||||||
<%= for field <- adapter.fields do %>
|
<%= for field <- @selected_adapter.fields do %>
|
||||||
<.adapter_field_static
|
<.adapter_field_static
|
||||||
field_def={field}
|
field_def={field}
|
||||||
value={if selected, do: @current_values[field.key]}
|
value={@current_values[field.key]}
|
||||||
disabled={!selected || @env_locked}
|
disabled={@env_locked}
|
||||||
error={if selected, do: @field_errors[field.key]}
|
error={@field_errors[field.key]}
|
||||||
/>
|
/>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= unless @env_locked do %>
|
<%= unless @env_locked do %>
|
||||||
<div class="admin-row admin-row-lg">
|
<div class="admin-row admin-row-lg">
|
||||||
<.button phx-disable-with="Saving..." disabled={!selected}>
|
<.button phx-disable-with="Saving...">
|
||||||
Save settings
|
Save settings
|
||||||
</.button>
|
</.button>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
|
||||||
|
<%!-- 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>
|
</.form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -393,16 +371,17 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
<% else %>
|
<% else %>
|
||||||
<%= if @test_result == :error do %>
|
<%= if @test_result == :error do %>
|
||||||
<p class="admin-test-error">{@test_error}</p>
|
<p class="admin-test-error">{@test_error}</p>
|
||||||
<div>
|
<div class="admin-row admin-row-sm">
|
||||||
<%= if @test_retryable do %>
|
<%= if @test_retryable do %>
|
||||||
<button
|
<.button
|
||||||
|
type="button"
|
||||||
phx-click="send_test"
|
phx-click="send_test"
|
||||||
disabled={@sending_test}
|
disabled={@sending_test}
|
||||||
class="admin-btn admin-btn-outline admin-btn-error"
|
class="admin-btn admin-btn-outline admin-btn-error"
|
||||||
>
|
>
|
||||||
<.icon name="hero-paper-airplane" class="size-4" />
|
<.icon name="hero-paper-airplane" class="size-4" />
|
||||||
{if @sending_test, do: "Sending...", else: "Try again"}
|
{if @sending_test, do: "Sending...", else: "Try again"}
|
||||||
</button>
|
</.button>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p class="admin-setup-step-desc">
|
<p class="admin-setup-step-desc">
|
||||||
Fix your settings above and reconnect, then try the test again.
|
Fix your settings above and reconnect, then try the test again.
|
||||||
@ -413,15 +392,26 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
<p class="admin-setup-step-desc">
|
<p class="admin-setup-step-desc">
|
||||||
Send a test to <strong>{@current_scope.user.email}</strong> to check everything works.
|
Send a test to <strong>{@current_scope.user.email}</strong> to check everything works.
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div class="admin-row admin-row-sm">
|
||||||
<button
|
<.button
|
||||||
|
type="button"
|
||||||
phx-click="send_test"
|
phx-click="send_test"
|
||||||
disabled={@sending_test}
|
disabled={@sending_test}
|
||||||
class="admin-btn admin-btn-outline"
|
phx-disable-with="Sending..."
|
||||||
>
|
>
|
||||||
<.icon name="hero-paper-airplane" class="size-4" />
|
<.icon name="hero-paper-airplane" class="size-4" /> Send test email
|
||||||
{if @sending_test, do: "Sending...", else: "Send test email"}
|
</.button>
|
||||||
</button>
|
<%!-- No-JS fallback for test email --%>
|
||||||
|
<noscript>
|
||||||
|
<.form
|
||||||
|
for={%{}}
|
||||||
|
action={~p"/admin/settings/email/test"}
|
||||||
|
method="post"
|
||||||
|
style="display:inline"
|
||||||
|
>
|
||||||
|
<.button>Send test email</.button>
|
||||||
|
</.form>
|
||||||
|
</noscript>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
@ -501,7 +491,6 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
label={@field_def.label}
|
label={@field_def.label}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
placeholder={@value || ""}
|
placeholder={@value || ""}
|
||||||
required={@field_def.required && !@value}
|
|
||||||
disabled={@disabled}
|
disabled={@disabled}
|
||||||
errors={@errors}
|
errors={@errors}
|
||||||
/>
|
/>
|
||||||
@ -517,7 +506,6 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
value={@value || @field_def.default || ""}
|
value={@value || @field_def.default || ""}
|
||||||
type="number"
|
type="number"
|
||||||
label={@field_def.label}
|
label={@field_def.label}
|
||||||
required={@field_def.required}
|
|
||||||
disabled={@disabled}
|
disabled={@disabled}
|
||||||
errors={@errors}
|
errors={@errors}
|
||||||
/>
|
/>
|
||||||
@ -533,7 +521,6 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
value={@value || @field_def.default || ""}
|
value={@value || @field_def.default || ""}
|
||||||
type="text"
|
type="text"
|
||||||
label={@field_def.label}
|
label={@field_def.label}
|
||||||
required={@field_def.required}
|
|
||||||
disabled={@disabled}
|
disabled={@disabled}
|
||||||
errors={@errors}
|
errors={@errors}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -138,6 +138,10 @@ defmodule BerrypodWeb.Router do
|
|||||||
get "/analytics/export", AnalyticsExportController, :export
|
get "/analytics/export", AnalyticsExportController, :export
|
||||||
get "/newsletter/export", NewsletterExportController, :export
|
get "/newsletter/export", NewsletterExportController, :export
|
||||||
|
|
||||||
|
# No-JS fallbacks for email settings
|
||||||
|
post "/settings/email", EmailSettingsController, :update
|
||||||
|
post "/settings/email/test", EmailSettingsController, :test
|
||||||
|
|
||||||
live_session :admin,
|
live_session :admin,
|
||||||
layout: {BerrypodWeb.Layouts, :admin},
|
layout: {BerrypodWeb.Layouts, :admin},
|
||||||
on_mount: [
|
on_mount: [
|
||||||
|
|||||||
@ -207,6 +207,75 @@ defmodule Berrypod.MailerTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "save_config/3" do
|
||||||
|
test "saves valid adapter config" do
|
||||||
|
assert {:ok, _} =
|
||||||
|
Mailer.save_config("brevo", %{"api_key" => "xkeysib-abc123def456"}, "a@b.com")
|
||||||
|
|
||||||
|
assert Settings.get_setting("email_adapter") == "brevo"
|
||||||
|
assert Settings.has_secret?("email_brevo_api_key")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error for missing required fields" do
|
||||||
|
assert {:error, errors} = Mailer.save_config("brevo", %{"api_key" => ""}, "a@b.com")
|
||||||
|
assert errors["api_key"] =~ "required"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error for invalid key format" do
|
||||||
|
assert {:error, errors} =
|
||||||
|
Mailer.save_config("sendgrid", %{"api_key" => "not-a-sendgrid-key"}, "a@b.com")
|
||||||
|
|
||||||
|
assert errors["api_key"] =~ "SG."
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error for invalid integer" do
|
||||||
|
assert {:error, errors} =
|
||||||
|
Mailer.save_config(
|
||||||
|
"smtp",
|
||||||
|
%{"relay" => "smtp.example.com", "port" => "abc"},
|
||||||
|
"a@b.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert errors["port"] =~ "number"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error for unknown adapter" do
|
||||||
|
assert {:error, errors} = Mailer.save_config("unknown", %{}, "a@b.com")
|
||||||
|
assert errors["_base"] =~ "select"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "auto-sets from address when not configured" do
|
||||||
|
Settings.delete_setting("email_from_address")
|
||||||
|
Mailer.save_config("brevo", %{"api_key" => "xkeysib-abc123def456"}, "admin@shop.com")
|
||||||
|
assert Settings.get_setting("email_from_address") == "admin@shop.com"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "clears email verified flag" do
|
||||||
|
Application.put_env(:berrypod, Mailer, adapter: Swoosh.Adapters.Postmark, api_key: "test")
|
||||||
|
Mailer.mark_email_verified()
|
||||||
|
assert Mailer.email_verified?()
|
||||||
|
|
||||||
|
Mailer.save_config("brevo", %{"api_key" => "xkeysib-abc123def456"}, "a@b.com")
|
||||||
|
refute Mailer.email_verified?()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "clears settings from other providers" do
|
||||||
|
Settings.put_secret("email_mailjet_api_key", "old-key")
|
||||||
|
Settings.put_secret("email_mailjet_secret", "old-secret")
|
||||||
|
|
||||||
|
Mailer.save_config("brevo", %{"api_key" => "xkeysib-abc123def456"}, "a@b.com")
|
||||||
|
|
||||||
|
refute Settings.has_secret?("email_mailjet_api_key")
|
||||||
|
refute Settings.has_secret?("email_mailjet_secret")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "trims whitespace from values" do
|
||||||
|
Mailer.save_config("brevo", %{"api_key" => " xkeysib-abc123def456 "}, "a@b.com")
|
||||||
|
# Key should be saved trimmed (verified via successful save — no format error)
|
||||||
|
assert Settings.get_setting("email_adapter") == "brevo"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "current_config/0" do
|
describe "current_config/0" do
|
||||||
test "returns {nil, %{}} when no adapter configured" do
|
test "returns {nil, %{}} when no adapter configured" do
|
||||||
Application.put_env(:berrypod, Mailer, adapter: Swoosh.Adapters.Local)
|
Application.put_env(:berrypod, Mailer, adapter: Swoosh.Adapters.Local)
|
||||||
|
|||||||
@ -0,0 +1,76 @@
|
|||||||
|
defmodule BerrypodWeb.EmailSettingsControllerTest do
|
||||||
|
use BerrypodWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Berrypod.AccountsFixtures
|
||||||
|
|
||||||
|
alias Berrypod.Settings
|
||||||
|
|
||||||
|
setup do
|
||||||
|
original = Application.get_env(:berrypod, Berrypod.Mailer)
|
||||||
|
Application.put_env(:berrypod, Berrypod.Mailer, adapter: Swoosh.Adapters.Test)
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
Application.put_env(:berrypod, Berrypod.Mailer, original)
|
||||||
|
end)
|
||||||
|
|
||||||
|
user = user_fixture()
|
||||||
|
conn = build_conn() |> log_in_user(user)
|
||||||
|
%{conn: conn, user: user}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "POST /admin/settings/email" do
|
||||||
|
test "saves adapter config and redirects", %{conn: conn} do
|
||||||
|
conn =
|
||||||
|
post(conn, ~p"/admin/settings/email", %{
|
||||||
|
email: %{
|
||||||
|
adapter: "brevo",
|
||||||
|
configured_adapter: "brevo",
|
||||||
|
api_key: "xkeysib-abc123def456"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert redirected_to(conn) == ~p"/admin/settings/email"
|
||||||
|
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Settings saved"
|
||||||
|
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: ""}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert redirected_to(conn) =~ ~p"/admin/settings/email"
|
||||||
|
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "required"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
conn = post(conn, ~p"/admin/settings/email/test")
|
||||||
|
|
||||||
|
assert redirected_to(conn) == ~p"/admin/settings/email"
|
||||||
|
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Test email sent"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "unauthenticated" do
|
||||||
|
test "redirects to login", %{conn: _conn} do
|
||||||
|
conn = build_conn()
|
||||||
|
conn = post(conn, ~p"/admin/settings/email", %{email: %{adapter: "brevo"}})
|
||||||
|
assert redirected_to(conn) =~ ~p"/users/log-in"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -37,11 +37,33 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|
|||||||
assert html =~ "Postal"
|
assert html =~ "Postal"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "shows all three provider groups", %{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"
|
||||||
|
assert html =~ "Postal"
|
||||||
|
end
|
||||||
|
|
||||||
test "shows setup guidance", %{conn: conn} do
|
test "shows setup guidance", %{conn: conn} do
|
||||||
{:ok, _view, html} = live(conn, ~p"/admin/settings/email")
|
{:ok, _view, html} = live(conn, ~p"/admin/settings/email")
|
||||||
|
|
||||||
assert html =~ "needs an email provider"
|
assert html =~ "needs an email provider"
|
||||||
assert html =~ "Paste your API key"
|
|
||||||
assert html =~ "300 emails/day free"
|
assert html =~ "300 emails/day free"
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -51,7 +73,7 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|
|||||||
# Select SMTP via form change (radio inputs fire phx-change)
|
# Select SMTP via form change (radio inputs fire phx-change)
|
||||||
html =
|
html =
|
||||||
view
|
view
|
||||||
|> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "smtp"}})
|
|> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "smtp"}})
|
||||||
|> render_change()
|
|> render_change()
|
||||||
|
|
||||||
assert html =~ "Server host"
|
assert html =~ "Server host"
|
||||||
@ -66,19 +88,31 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|
|||||||
# Select Brevo which needs just an api_key
|
# Select Brevo which needs just an api_key
|
||||||
html =
|
html =
|
||||||
view
|
view
|
||||||
|> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "brevo"}})
|
|> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "brevo"}})
|
||||||
|> render_change()
|
|> render_change()
|
||||||
|
|
||||||
assert html =~ "API key"
|
assert html =~ "API key"
|
||||||
assert html =~ "Brevo"
|
assert html =~ "Brevo"
|
||||||
end
|
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
|
test "saving config persists settings", %{conn: conn} do
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
||||||
|
|
||||||
# Select Brevo via form change
|
# Select Brevo via form change
|
||||||
view
|
view
|
||||||
|> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "brevo"}})
|
|> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "brevo"}})
|
||||||
|> render_change()
|
|> render_change()
|
||||||
|
|
||||||
# Submit with an API key
|
# Submit with an API key
|
||||||
@ -98,7 +132,7 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|
|||||||
|
|
||||||
# Select Brevo
|
# Select Brevo
|
||||||
view
|
view
|
||||||
|> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "brevo"}})
|
|> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "brevo"}})
|
||||||
|> render_change()
|
|> render_change()
|
||||||
|
|
||||||
# Submit without API key
|
# Submit without API key
|
||||||
@ -110,6 +144,23 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|
|||||||
assert html =~ "API key is required"
|
assert html =~ "API key is required"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "saving with invalid port shows error", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
||||||
|
|
||||||
|
view
|
||||||
|
|> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "smtp"}})
|
||||||
|
|> render_change()
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> form("form[phx-submit=\"save\"]", %{
|
||||||
|
email: %{adapter: "smtp", relay: "smtp.example.com", port: "abc"}
|
||||||
|
})
|
||||||
|
|> render_submit()
|
||||||
|
|
||||||
|
assert html =~ "must be a number"
|
||||||
|
end
|
||||||
|
|
||||||
test "shows test email section when configured", %{conn: conn} do
|
test "shows test email section when configured", %{conn: conn} do
|
||||||
Settings.put_setting("email_adapter", "postmark")
|
Settings.put_setting("email_adapter", "postmark")
|
||||||
Settings.put_secret("email_postmark_api_key", "pm_test_abc")
|
Settings.put_secret("email_postmark_api_key", "pm_test_abc")
|
||||||
@ -136,7 +187,10 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|
|||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
||||||
|
|
||||||
html = render_click(view, "send_test")
|
# send_test is now async — click triggers the event, then handle_info completes it
|
||||||
|
render_click(view, "send_test")
|
||||||
|
# Wait for the async handle_info to complete
|
||||||
|
html = render(view)
|
||||||
|
|
||||||
assert html =~ "Email is working"
|
assert html =~ "Email is working"
|
||||||
assert html =~ "Send again"
|
assert html =~ "Send again"
|
||||||
@ -153,7 +207,7 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|
|||||||
|
|
||||||
# Switch to Brevo and save
|
# Switch to Brevo and save
|
||||||
view
|
view
|
||||||
|> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "brevo"}})
|
|> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "brevo"}})
|
||||||
|> render_change()
|
|> render_change()
|
||||||
|
|
||||||
view
|
view
|
||||||
@ -177,7 +231,7 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|
|||||||
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
||||||
|
|
||||||
view
|
view
|
||||||
|> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "brevo"}})
|
|> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "brevo"}})
|
||||||
|> render_change()
|
|> render_change()
|
||||||
|
|
||||||
view
|
view
|
||||||
@ -188,6 +242,46 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|
|||||||
|
|
||||||
refute Mailer.email_verified?()
|
refute Mailer.email_verified?()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "phx-change is no-op when adapter hasn't changed", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
||||||
|
|
||||||
|
# Select Brevo
|
||||||
|
view
|
||||||
|
|> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "brevo"}})
|
||||||
|
|> render_change()
|
||||||
|
|
||||||
|
# Trigger another change with the same adapter (simulates text field input)
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "brevo"}})
|
||||||
|
|> render_change()
|
||||||
|
|
||||||
|
# Should still show Brevo config
|
||||||
|
assert html =~ "Brevo"
|
||||||
|
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")
|
||||||
|
|
||||||
|
assert html =~ "setting up email"
|
||||||
|
assert html =~ "Back to checklist"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "has fieldset legend for accessibility", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/admin/settings/email")
|
||||||
|
|
||||||
|
assert html =~ "Email provider"
|
||||||
|
assert html =~ "sr-only"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "unauthenticated" do
|
describe "unauthenticated" do
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user