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:
jamey
2026-03-04 21:26:59 +00:00
parent dd659e4c61
commit dd20ea824f
9 changed files with 637 additions and 235 deletions

View File

@@ -1,6 +1,7 @@
defmodule Berrypod.Mailer do
use Swoosh.Mailer, otp_app: :berrypod
alias Berrypod.KeyValidation
alias Berrypod.Mailer.Adapters
alias Berrypod.Settings
@@ -132,6 +133,117 @@ defmodule Berrypod.Mailer do
Settings.get_setting("email_from_address") || "noreply@#{default_from_domain()}"
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 """
Turns a raw delivery error into a user-friendly message.