diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index c49fdc0..e5d554d 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -1459,6 +1459,10 @@ grid-template-columns: repeat(2, 1fr); gap: 0.5rem; margin-top: 0.5rem; + + @media (max-width: 30rem) { + grid-template-columns: 1fr; + } } .card-radio-card { @@ -1483,6 +1487,11 @@ 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 { border-color: var(--t-text-primary, #171717); background: var(--t-surface-sunken, #e5e5e5); @@ -4272,7 +4281,7 @@ } .card-radio-recommended { - background: var(--admin-accent, oklch(0.65 0.2 145)); + background: oklch(0.45 0.15 145); color: white; font-weight: 500; } @@ -4338,6 +4347,29 @@ 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 ── */ .admin-adapter-config { @@ -4345,10 +4377,6 @@ display: flex; flex-direction: column; gap: 1.5rem; - - &[hidden] { - display: none; - } } /* ── Campaign form ── */ diff --git a/assets/js/app.js b/assets/js/app.js index 2f20a0d..e30bb93 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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 // so the download URL is always correct, even if clicked before the LiveView re-render. const AnalyticsExport = { @@ -704,7 +682,7 @@ const EditorKeyboard = { const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") const liveSocket = new LiveSocket("/live", Socket, { 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 diff --git a/lib/berrypod/mailer.ex b/lib/berrypod/mailer.ex index dc69506..44636ff 100644 --- a/lib/berrypod/mailer.ex +++ b/lib/berrypod/mailer.ex @@ -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. diff --git a/lib/berrypod_web/controllers/email_settings_controller.ex b/lib/berrypod_web/controllers/email_settings_controller.ex new file mode 100644 index 0000000..5594de1 --- /dev/null +++ b/lib/berrypod_web/controllers/email_settings_controller.ex @@ -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 diff --git a/lib/berrypod_web/live/admin/email_settings.ex b/lib/berrypod_web/live/admin/email_settings.ex index 2ff39ac..1375be5 100644 --- a/lib/berrypod_web/live/admin/email_settings.ex +++ b/lib/berrypod_web/live/admin/email_settings.ex @@ -1,7 +1,6 @@ defmodule BerrypodWeb.Admin.EmailSettings do use BerrypodWeb, :live_view - alias Berrypod.KeyValidation alias Berrypod.Mailer alias Berrypod.Mailer.Adapters alias Berrypod.Settings @@ -21,8 +20,8 @@ defmodule BerrypodWeb.Admin.EmailSettings do |> assign(:env_locked, env_locked) |> assign(:adapter_key, adapter_key) |> assign(:current_values, current_values) - |> assign(:all_adapters, Adapters.all()) - |> assign(:recommended_adapters, grouped[:all_email] || []) + |> assign(:all_email_adapters, grouped[:all_email] || []) + |> assign(:transactional_adapters, grouped[:transactional] || []) |> assign(:advanced_adapters, grouped[:advanced] || []) |> assign(:email_configured, Mailer.email_configured?()) |> assign(:selected_adapter, adapter_key && Adapters.get(adapter_key)) @@ -37,9 +36,29 @@ 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 -> @@ -61,17 +80,21 @@ defmodule BerrypodWeb.Admin.EmailSettings do end @impl true - def handle_event("change_adapter", %{"email" => %{"adapter" => key}}, socket) do - values = load_adapter_values(key) + 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) - {: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)} + {: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)} + end end def handle_event("save", %{"email" => params}, socket) do @@ -79,20 +102,36 @@ defmodule BerrypodWeb.Admin.EmailSettings do {:noreply, put_flash(socket, :error, "Email config is controlled by environment variables")} else adapter_key = params["adapter"] - adapter_info = Adapters.get(adapter_key) - if adapter_info do - save_adapter_config(socket, adapter_info, params) - else - {:noreply, put_flash(socket, :error, "Please select an email provider")} + case Mailer.save_config(adapter_key, 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(: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 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 {:ok, _} -> @@ -114,103 +153,8 @@ defmodule BerrypodWeb.Admin.EmailSettings do end end - defp save_adapter_config(socket, adapter_info, params) do - # Trim all values - 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 + # Swoosh test adapter sends {:email, ...} messages — ignore them + def handle_info({:email, _}, socket), do: {:noreply, socket} @impl true def render(assigns) do @@ -253,94 +197,128 @@ defmodule BerrypodWeb.Admin.EmailSettings do <% end %>
- <.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 --%> + + <%!-- Step 1: Choose a provider --%>
1

Choose a provider

-

- All of these have a free tier. Pick whichever you like. -

-
-
+ +
+ Email provider + +

Popular providers

+

+ Newsletters and transactional emails. All have free tiers. + + Not sure which? Pick the recommended one. + +

+
+ <.provider_card + :for={adapter <- @all_email_adapters} + adapter={adapter} + selected={@adapter_key} + disabled={@env_locked} + /> +
+ +

Transactional only

+

+ Order confirmations and shipping updates. + You'll need a separate service for newsletters later. +

+
+ <.provider_card + :for={adapter <- @transactional_adapters} + adapter={adapter} + selected={@adapter_key} + disabled={@env_locked} + /> +
+ +
+ + Already have your own email server? +
<.provider_card - :for={adapter <- @recommended_adapters} + :for={adapter <- @advanced_adapters} adapter={adapter} selected={@adapter_key} disabled={@env_locked} />
+
+
+
-
- - Already have your own email server? - -
- <.provider_card - :for={adapter <- @advanced_adapters} - adapter={adapter} - selected={@adapter_key} - disabled={@env_locked} - /> -
-
- + <%!-- Steps 2 & 3 appear for the selected adapter only --%> +
+ <%!-- Step 2: Create an account (providers with sign-up URLs) --%> +
+
+ 2 +

Create a free account

+
+

+ <.external_link href={@selected_adapter.url} class="admin-link"> + Sign up at {@selected_adapter.name} ↗ + + if you don't already have an account. It's free. +

+
+ + <%!-- Step 3 (or 2 for advanced): Paste your key --%> +
+
+ + {if @selected_adapter.url, do: "3", else: "2"} + +

{adapter_fields_title(@selected_adapter)}

+
+

{adapter_fields_instruction(@selected_adapter)}

+ <%= 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]} + /> + <% end %> + <%= unless @env_locked do %> +
+ <.button phx-disable-with="Saving..."> + Save settings + +
+ <% end %>
- <%!-- Steps 2 & 3 appear per-adapter after selection --%> - <%= for adapter <- @all_adapters do %> - <% selected = @adapter_key == adapter.key %> -
@@ -393,16 +371,17 @@ defmodule BerrypodWeb.Admin.EmailSettings do <% else %> <%= if @test_result == :error do %>

{@test_error}

-
+
<%= if @test_retryable do %> - + <% else %>

Fix your settings above and reconnect, then try the test again. @@ -413,15 +392,26 @@ defmodule BerrypodWeb.Admin.EmailSettings do

Send a test to {@current_scope.user.email} to check everything works.

-
- + <.icon name="hero-paper-airplane" class="size-4" /> Send test email + + <%!-- No-JS fallback for test email --%> +
<% end %> <% end %> @@ -501,7 +491,6 @@ defmodule BerrypodWeb.Admin.EmailSettings do label={@field_def.label} autocomplete="off" placeholder={@value || ""} - required={@field_def.required && !@value} disabled={@disabled} errors={@errors} /> @@ -517,7 +506,6 @@ defmodule BerrypodWeb.Admin.EmailSettings do value={@value || @field_def.default || ""} type="number" label={@field_def.label} - required={@field_def.required} disabled={@disabled} errors={@errors} /> @@ -533,7 +521,6 @@ defmodule BerrypodWeb.Admin.EmailSettings do value={@value || @field_def.default || ""} type="text" label={@field_def.label} - required={@field_def.required} disabled={@disabled} errors={@errors} /> diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex index d469d2c..3161901 100644 --- a/lib/berrypod_web/router.ex +++ b/lib/berrypod_web/router.ex @@ -138,6 +138,10 @@ defmodule BerrypodWeb.Router do get "/analytics/export", AnalyticsExportController, :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, layout: {BerrypodWeb.Layouts, :admin}, on_mount: [ diff --git a/test/berrypod/mailer_test.exs b/test/berrypod/mailer_test.exs index d0c2cce..62bb19b 100644 --- a/test/berrypod/mailer_test.exs +++ b/test/berrypod/mailer_test.exs @@ -207,6 +207,75 @@ defmodule Berrypod.MailerTest do 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 test "returns {nil, %{}} when no adapter configured" do Application.put_env(:berrypod, Mailer, adapter: Swoosh.Adapters.Local) diff --git a/test/berrypod_web/controllers/email_settings_controller_test.exs b/test/berrypod_web/controllers/email_settings_controller_test.exs new file mode 100644 index 0000000..8300852 --- /dev/null +++ b/test/berrypod_web/controllers/email_settings_controller_test.exs @@ -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 diff --git a/test/berrypod_web/live/admin/email_settings_test.exs b/test/berrypod_web/live/admin/email_settings_test.exs index 57b95db..5333ba4 100644 --- a/test/berrypod_web/live/admin/email_settings_test.exs +++ b/test/berrypod_web/live/admin/email_settings_test.exs @@ -37,11 +37,33 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do assert html =~ "Postal" 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 {:ok, _view, html} = live(conn, ~p"/admin/settings/email") assert html =~ "needs an email provider" - assert html =~ "Paste your API key" assert html =~ "300 emails/day free" end @@ -51,7 +73,7 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do # Select SMTP via form change (radio inputs fire phx-change) html = view - |> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "smtp"}}) + |> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "smtp"}}) |> render_change() assert html =~ "Server host" @@ -66,19 +88,31 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do # Select Brevo which needs just an api_key html = view - |> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "brevo"}}) + |> 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 {:ok, view, _html} = live(conn, ~p"/admin/settings/email") # Select Brevo via form change view - |> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "brevo"}}) + |> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "brevo"}}) |> render_change() # Submit with an API key @@ -98,7 +132,7 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do # Select Brevo view - |> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "brevo"}}) + |> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "brevo"}}) |> render_change() # Submit without API key @@ -110,6 +144,23 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do assert html =~ "API key is required" 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 Settings.put_setting("email_adapter", "postmark") 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") - 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 =~ "Send again" @@ -153,7 +207,7 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do # Switch to Brevo and save view - |> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "brevo"}}) + |> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "brevo"}}) |> render_change() view @@ -177,7 +231,7 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do {:ok, view, _html} = live(conn, ~p"/admin/settings/email") view - |> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "brevo"}}) + |> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "brevo"}}) |> render_change() view @@ -188,6 +242,46 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do refute Mailer.email_verified?() 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 describe "unauthenticated" do