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

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