From 366a1e6a48ce6e29037823311ed02ebb8e3daacf Mon Sep 17 00:00:00 2001 From: jamey Date: Sat, 21 Feb 2026 19:29:34 +0000 Subject: [PATCH] add admin email settings page with provider selection Card radio component for picking email providers (SMTP, SendGrid, Mailjet, etc.) with instant client-side switching via JS hook. Adapter configs are pre-rendered and toggled without a server round-trip. Secrets are preserved when re-saving with blank password fields. Includes from address field, test email sending, and disconnect flow. Co-Authored-By: Claude Opus 4.6 --- assets/css/admin/components.css | 76 +++- assets/js/app.js | 24 +- lib/berrypod/application.ex | 2 + lib/berrypod/mailer.ex | 136 +++++++ lib/berrypod/mailer/adapter.ex | 5 + lib/berrypod/mailer/adapters.ex | 140 +++++++ lib/berrypod/mailer/field.ex | 5 + .../components/core_components.ex | 93 +++++ lib/berrypod_web/components/layouts.ex | 4 + .../components/layouts/admin.html.heex | 11 + lib/berrypod_web/live/admin/email_settings.ex | 355 ++++++++++++++++++ lib/berrypod_web/live/setup/onboarding.ex | 48 +-- lib/berrypod_web/router.ex | 1 + test/berrypod/mailer/adapters_test.exs | 78 ++++ test/berrypod/mailer_test.exs | 79 ++++ .../live/admin/email_settings_test.exs | 154 ++++++++ .../live/setup/onboarding_test.exs | 4 +- 17 files changed, 1176 insertions(+), 39 deletions(-) create mode 100644 lib/berrypod/mailer/adapter.ex create mode 100644 lib/berrypod/mailer/adapters.ex create mode 100644 lib/berrypod/mailer/field.ex create mode 100644 lib/berrypod_web/live/admin/email_settings.ex create mode 100644 test/berrypod/mailer/adapters_test.exs create mode 100644 test/berrypod/mailer_test.exs create mode 100644 test/berrypod_web/live/admin/email_settings_test.exs diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index 1bf0630..ef7fde2 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -818,15 +818,21 @@ gap: 0.25rem; } -/* Provider picker grid */ -.setup-provider-grid { +/* Card radio group — selectable cards backed by radio inputs */ +.card-radio-fieldset { + border: none; + padding: 0; + margin: 0; +} + +.card-radio-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem; - margin-bottom: 1rem; + margin-top: 0.5rem; } -.setup-provider-card { +.card-radio-card { display: flex; flex-direction: column; align-items: flex-start; @@ -834,36 +840,48 @@ padding: 0.75rem; border: 1px solid var(--t-border-default, #d4d4d4); border-radius: 0.375rem; - text-align: left; cursor: pointer; transition: border-color 150ms, background 150ms; + -webkit-tap-highlight-color: transparent; - &:hover:not(:disabled) { + @media (hover: hover) { + &:hover:not(:has(:disabled)) { + border-color: color-mix(in oklch, var(--t-text-primary) 40%, transparent); + } + } + + &:active:not(:has(:disabled)) { + background: color-mix(in oklch, var(--t-surface-sunken) 50%, transparent); + } + + &.card-radio-card-selected { border-color: var(--t-text-primary, #171717); + background: var(--t-surface-sunken, #e5e5e5); } } -.setup-provider-card-selected { - border-color: var(--t-text-primary, #171717); - background: var(--t-surface-sunken, #e5e5e5); -} - -.setup-provider-card-disabled { +.card-radio-card-disabled { opacity: 0.5; cursor: not-allowed; } -.setup-provider-name { +.card-radio-input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.card-radio-name { font-size: 0.875rem; font-weight: 600; } -.setup-provider-tagline { +.card-radio-description { font-size: 0.75rem; color: color-mix(in oklch, var(--t-text-primary) 60%, transparent); } -.setup-provider-badge { +.card-radio-badge { font-size: 0.6875rem; padding: 0.125rem 0.375rem; border-radius: 9999px; @@ -871,6 +889,34 @@ color: color-mix(in oklch, var(--t-text-primary) 60%, transparent); } +.card-radio-link { + font-size: 0.6875rem; + color: color-mix(in oklch, var(--t-text-primary) 60%, transparent); + text-decoration: underline; + text-underline-offset: 2px; + + &:hover { + color: var(--t-text-primary, #171717); + } +} + +/* Tags display mode */ +.card-radio-tags { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.125rem; +} + +.card-radio-tag { + font-size: 0.625rem; + padding: 0.0625rem 0.375rem; + border-radius: 9999px; + background: var(--t-surface-sunken, #e5e5e5); + color: color-mix(in oklch, var(--t-text-primary) 70%, transparent); + white-space: nowrap; +} + .setup-provider-form { margin-top: 0.75rem; } diff --git a/assets/js/app.js b/assets/js/app.js index e70cd79..7a3c043 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -487,10 +487,32 @@ 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" }) + }) + } +} + const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") const liveSocket = new LiveSocket("/live", Socket, { params: {_csrf_token: csrfToken}, - hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, CollectionFilters}, + hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, CollectionFilters, CardRadioScroll}, }) // Show progress bar on live navigation and form submits diff --git a/lib/berrypod/application.ex b/lib/berrypod/application.ex index c8c8bfa..b2e2e8f 100644 --- a/lib/berrypod/application.ex +++ b/lib/berrypod/application.ex @@ -16,6 +16,8 @@ defmodule Berrypod.Application do Supervisor.child_spec({Task, &Berrypod.Release.seed_defaults/0}, id: :seed_defaults), # Load encrypted secrets from DB into Application env {Task, &Berrypod.Secrets.load_all/0}, + # Load email adapter config from DB (after secrets are available) + Supervisor.child_spec({Task, &Berrypod.Mailer.load_config/0}, id: :load_email_config), {DNSCluster, query: Application.get_env(:berrypod, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: Berrypod.PubSub}, # Background job processing diff --git a/lib/berrypod/mailer.ex b/lib/berrypod/mailer.ex index d427af3..305e1e7 100644 --- a/lib/berrypod/mailer.ex +++ b/lib/berrypod/mailer.ex @@ -1,6 +1,9 @@ defmodule Berrypod.Mailer do use Swoosh.Mailer, otp_app: :berrypod + alias Berrypod.Mailer.Adapters + alias Berrypod.Settings + @doc """ Returns whether a real email adapter is configured. @@ -11,4 +14,137 @@ defmodule Berrypod.Mailer do adapter = Application.get_env(:berrypod, __MODULE__)[:adapter] adapter != nil and adapter != Swoosh.Adapters.Local end + + @doc """ + Returns true if email is configured via environment variables (SMTP_HOST). + + When env vars are active, the admin UI shows the config as read-only. + """ + def env_var_configured? do + System.get_env("SMTP_HOST") != nil + end + + @doc """ + Returns the current adapter key and config for display in the admin UI. + + Returns `{adapter_key, config_map}` or `{nil, %{}}` if using the default. + """ + def current_config do + mailer_config = Application.get_env(:berrypod, __MODULE__, []) + adapter = mailer_config[:adapter] + + case Enum.find(Adapters.all(), &(&1.module == adapter)) do + nil -> + {nil, %{}} + + adapter_info -> + config = + for field <- adapter_info.fields, into: %{} do + value = + case field.type do + :secret -> Settings.secret_hint("email_#{field.key}") + _ -> Settings.get_setting("email_#{field.key}") + end + + {field.key, value} + end + + {adapter_info.key, config} + end + end + + @doc """ + Loads email config from the Settings table and applies it to Application env. + + Env vars take precedence — if SMTP_HOST is set, this is a no-op since + runtime.exs already configured the adapter. + + Called on boot (from Application.start) and after admin saves email settings. + """ + def load_config do + if env_var_configured?() do + :ok + else + case Settings.get_setting("email_adapter") do + nil -> + :ok + + adapter_key -> + case Adapters.get(adapter_key) do + nil -> + :ok + + adapter_info -> + config = build_config(adapter_info) + Application.put_env(:berrypod, __MODULE__, config) + # API-based adapters need a real HTTP client (dev defaults to false) + if adapter_info.module != Swoosh.Adapters.SMTP do + Application.put_env(:swoosh, :api_client, Swoosh.ApiClient.Req) + end + + :ok + end + end + end + end + + @doc """ + Sends a test email to the given address using the current config. + """ + def send_test_email(to_address, from \\ nil) do + import Swoosh.Email + + email = + new() + |> to(to_address) + |> from({"Berrypod", from || from_address()}) + |> subject("Berrypod test email") + |> text_body("This is a test email from your Berrypod shop. Email delivery is working.") + + deliver(email) + end + + @doc "Returns the configured from address for outbound email." + def from_address do + Settings.get_setting("email_from_address") || "noreply@#{default_from_domain()}" + end + + # Build Swoosh config keyword list from Settings for a given adapter + defp build_config(adapter_info) do + opts = + for field <- adapter_info.fields, reduce: [] do + acc -> + settings_key = "email_#{field.key}" + + value = + case field.type do + :secret -> Settings.get_secret(settings_key) + _ -> Settings.get_setting(settings_key) + end + + case {value, field} do + {nil, _} -> + acc + + {val, %{type: :integer}} -> + [{String.to_atom(field.key), to_integer(val)} | acc] + + {val, _} -> + [{String.to_atom(field.key), val} | acc] + end + end + + # SMTP uses :relay, others use the native Swoosh key names + [{:adapter, adapter_info.module} | opts] + end + + defp to_integer(val) when is_integer(val), do: val + defp to_integer(val) when is_binary(val), do: String.to_integer(val) + + defp default_from_domain do + case Application.get_env(:berrypod, BerrypodWeb.Endpoint)[:url][:host] do + nil -> "example.com" + host -> host + end + end end diff --git a/lib/berrypod/mailer/adapter.ex b/lib/berrypod/mailer/adapter.ex new file mode 100644 index 0000000..ac9f61b --- /dev/null +++ b/lib/berrypod/mailer/adapter.ex @@ -0,0 +1,5 @@ +defmodule Berrypod.Mailer.Adapter do + @moduledoc false + @enforce_keys [:key, :name, :module, :description, :tags, :fields] + defstruct [:key, :name, :module, :description, :tags, :fields, url: nil] +end diff --git a/lib/berrypod/mailer/adapters.ex b/lib/berrypod/mailer/adapters.ex new file mode 100644 index 0000000..8aed124 --- /dev/null +++ b/lib/berrypod/mailer/adapters.ex @@ -0,0 +1,140 @@ +defmodule Berrypod.Mailer.Adapters do + @moduledoc """ + Registry of supported email adapters and their config shapes. + + Each adapter entry defines the fields needed for configuration. + The UI renders dynamically from this registry. Adding a new adapter + is just adding an entry here — no other code changes needed. + """ + + alias Berrypod.Mailer.{Adapter, Field} + + # Ordered by capability: transactional + marketing first, + # then transactional only, then self-hosted. + @adapters [ + %Adapter{ + key: "smtp", + name: "SMTP", + module: Swoosh.Adapters.SMTP, + description: "Connect to any email server via SMTP. Works with most providers and hosts.", + tags: ["All email", "Any provider"], + fields: [ + %Field{key: "relay", label: "Server host", type: :string, required: true}, + %Field{key: "port", label: "Port", type: :integer, default: 587}, + %Field{key: "username", label: "Username", type: :string}, + %Field{key: "password", label: "Password", type: :secret} + ] + }, + %Adapter{ + key: "sendgrid", + name: "SendGrid", + module: Swoosh.Adapters.Sendgrid, + description: "Generous free tier, widely used.", + tags: ["All email", "US"], + url: "https://sendgrid.com", + fields: [ + %Field{key: "api_key", label: "API key", type: :secret, required: true} + ] + }, + %Adapter{ + key: "brevo", + name: "Brevo", + module: Swoosh.Adapters.Brevo, + description: "All-in-one platform, GDPR-friendly.", + tags: ["All email", "France", "GDPR"], + url: "https://www.brevo.com", + fields: [ + %Field{key: "api_key", label: "API key", type: :secret, required: true} + ] + }, + %Adapter{ + key: "mailjet", + name: "Mailjet", + module: Swoosh.Adapters.Mailjet, + description: "EU data processing, good free tier.", + tags: ["All email", "France", "GDPR"], + url: "https://www.mailjet.com", + fields: [ + %Field{key: "api_key", label: "API key", type: :secret, required: true}, + %Field{key: "secret", label: "Secret key", type: :secret, required: true} + ] + }, + %Adapter{ + key: "resend", + name: "Resend", + module: Swoosh.Adapters.Resend, + description: "Developer-friendly API, simple setup.", + tags: ["Transactional", "US"], + url: "https://resend.com", + fields: [ + %Field{key: "api_key", label: "API key", type: :secret, required: true} + ] + }, + %Adapter{ + key: "mailgun", + name: "Mailgun", + module: Swoosh.Adapters.Mailgun, + description: "EU region option available.", + tags: ["Transactional", "EU option", "Sweden"], + url: "https://www.mailgun.com", + fields: [ + %Field{key: "api_key", label: "API key", type: :secret, required: true}, + %Field{key: "domain", label: "Domain", type: :string, required: true} + ] + }, + %Adapter{ + key: "postmark", + name: "Postmark", + module: Swoosh.Adapters.Postmark, + description: "Excellent deliverability tracking.", + tags: ["Transactional", "US"], + url: "https://postmarkapp.com", + fields: [ + %Field{key: "api_key", label: "API key", type: :secret, required: true} + ] + }, + %Adapter{ + key: "mailpace", + name: "MailPace", + module: Swoosh.Adapters.MailPace, + description: "Privacy-focused, simple API.", + tags: ["Transactional", "UK"], + url: "https://mailpace.com", + fields: [ + %Field{key: "api_key", label: "API key", type: :secret, required: true} + ] + }, + %Adapter{ + key: "postal", + name: "Postal", + module: Swoosh.Adapters.Postal, + description: "Full control over your email infrastructure.", + tags: ["All email", "Self-hosted", "Open source"], + url: "https://docs.postalserver.io", + fields: [ + %Field{key: "api_key", label: "API key", type: :secret, required: true}, + %Field{key: "base_url", label: "Server URL", type: :string, required: true} + ] + } + ] + + @doc "Returns all supported adapters." + def all, do: @adapters + + @doc "Returns an adapter by its string key, or nil." + def get(key) when is_binary(key) do + Enum.find(@adapters, &(&1.key == key)) + end + + @doc "Returns the settings keys for an adapter's fields (prefixed with `email_`)." + def field_keys(%{fields: fields}) do + Enum.map(fields, &"email_#{&1.key}") + end + + @doc "Returns all possible settings keys across all adapters." + def all_field_keys do + @adapters + |> Enum.flat_map(&field_keys/1) + |> Enum.uniq() + end +end diff --git a/lib/berrypod/mailer/field.ex b/lib/berrypod/mailer/field.ex new file mode 100644 index 0000000..0803499 --- /dev/null +++ b/lib/berrypod/mailer/field.ex @@ -0,0 +1,5 @@ +defmodule Berrypod.Mailer.Field do + @moduledoc false + @enforce_keys [:key, :label, :type] + defstruct [:key, :label, :type, :default, required: false] +end diff --git a/lib/berrypod_web/components/core_components.ex b/lib/berrypod_web/components/core_components.ex index a070de0..2c9d77f 100644 --- a/lib/berrypod_web/components/core_components.ex +++ b/lib/berrypod_web/components/core_components.ex @@ -519,6 +519,99 @@ defmodule BerrypodWeb.CoreComponents do """ end + @doc """ + Renders a radio card group — a set of selectable cards backed by radio inputs. + + Each option is a map with `:value` and `:name`, plus optional `:description`, + `:tags`, `:url`, `:badge`, and `:disabled` keys. + + The `display` attr controls card content layout: + - `:tags` (default) — name + tag pills + short description + - `:description` — name + description text + link + + ## Examples + + <.card_radio_group + name="email[adapter]" + value={@selected} + legend="Email provider" + options={[ + %{value: "postmark", name: "Postmark", description: "Fast email.", tags: ["Transactional", "US"]}, + %{value: "smtp", name: "SMTP", description: "Any SMTP server.", tags: ["Any type"]} + ]} + /> + """ + attr :name, :string, required: true + attr :value, :string, default: nil + attr :legend, :string, required: true + attr :options, :list, required: true + attr :disabled, :boolean, default: false + attr :display, :atom, default: :tags, values: [:description, :tags] + + def card_radio_group(assigns) do + ~H""" +
+ {@legend} +
+ +
+
+ """ + end + + attr :option, :map, required: true + attr :display, :atom, required: true + + defp card_radio_content(%{display: :tags} = assigns) do + ~H""" + + {tag} + + + {@option.description} + + {@option.badge} + """ + end + + defp card_radio_content(assigns) do + ~H""" + + {@option.description} + + {@option.badge} + + {@option.name} ↗ + + """ + end + def show_modal(js \\ %JS{}, id) when is_binary(id) do js |> JS.exec("showModal()", to: "##{id}") diff --git a/lib/berrypod_web/components/layouts.ex b/lib/berrypod_web/components/layouts.ex index cdbc2c6..befdd58 100644 --- a/lib/berrypod_web/components/layouts.ex +++ b/lib/berrypod_web/components/layouts.ex @@ -50,6 +50,10 @@ defmodule BerrypodWeb.Layouts do if current_path == "/admin", do: "active", else: nil end + def admin_nav_active?(current_path, "/admin/settings") do + if current_path == "/admin/settings", do: "active", else: nil + end + def admin_nav_active?(current_path, link_path) do if String.starts_with?(current_path, link_path), do: "active", else: nil end diff --git a/lib/berrypod_web/components/layouts/admin.html.heex b/lib/berrypod_web/components/layouts/admin.html.heex index 81eaf93..0cc73d7 100644 --- a/lib/berrypod_web/components/layouts/admin.html.heex +++ b/lib/berrypod_web/components/layouts/admin.html.heex @@ -23,6 +23,9 @@ <.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />

Email delivery isn't set up yet — customers won't receive order confirmations or shipping updates. + <.link navigate={~p"/admin/settings/email"} class="underline font-medium"> + Configure email +

@@ -99,6 +102,14 @@ <.icon name="hero-cog-6-tooth" class="size-5" /> Settings +
  • + <.link + navigate={~p"/admin/settings/email"} + class={admin_nav_active?(@current_path, "/admin/settings/email")} + > + <.icon name="hero-envelope" class="size-5" /> Email + +
  • diff --git a/lib/berrypod_web/live/admin/email_settings.ex b/lib/berrypod_web/live/admin/email_settings.ex new file mode 100644 index 0000000..e3cd99d --- /dev/null +++ b/lib/berrypod_web/live/admin/email_settings.ex @@ -0,0 +1,355 @@ +defmodule BerrypodWeb.Admin.EmailSettings do + use BerrypodWeb, :live_view + + alias Berrypod.Mailer + alias Berrypod.Mailer.Adapters + alias Berrypod.Settings + + @impl true + def mount(_params, _session, socket) do + env_locked = Mailer.env_var_configured?() + {current_adapter, current_values} = Mailer.current_config() + saved_adapter = Settings.get_setting("email_adapter") + + adapter_key = current_adapter || saved_adapter + + {:ok, + socket + |> assign(:page_title, "Email settings") + |> assign(:env_locked, env_locked) + |> assign(:adapter_key, adapter_key) + |> assign(:current_values, current_values) + |> assign(:all_adapters, Adapters.all()) + |> assign(:provider_options, provider_options()) + |> assign(:email_configured, Mailer.email_configured?()) + |> assign( + :from_address, + Settings.get_setting("email_from_address") || socket.assigns.current_scope.user.email + ) + |> assign(:sending_test, false) + |> assign(:form, to_form(%{}, as: :email))} + end + + defp provider_options do + Enum.map(Adapters.all(), fn adapter -> + %{ + value: adapter.key, + name: adapter.name, + description: adapter.description, + tags: adapter.tags, + url: adapter.url + } + end) + end + + @impl true + def handle_event("change_adapter", %{"email" => %{"adapter" => key}}, socket) do + {:noreply, assign(socket, :adapter_key, key)} + end + + def handle_event("save", %{"email" => params}, socket) do + if socket.assigns.env_locked 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")} + end + end + end + + def handle_event("disconnect", _params, socket) do + if socket.assigns.env_locked do + {:noreply, put_flash(socket, :error, "Email config is controlled by environment variables")} + else + # Clear all email settings + Settings.delete_setting("email_adapter") + + for key <- Adapters.all_field_keys() do + Settings.delete_setting(key) + end + + # Reset to Local adapter + Application.put_env(:berrypod, Mailer, adapter: Swoosh.Adapters.Local) + + {:noreply, + socket + |> assign(:adapter_key, nil) + |> assign(:current_values, %{}) + |> assign(:email_configured, false) + |> put_flash(:info, "Email provider disconnected")} + end + end + + def handle_event("send_test", _params, socket) do + user = socket.assigns.current_scope.user + + socket = assign(socket, :sending_test, true) + + case Mailer.send_test_email(user.email, socket.assigns.from_address) do + {:ok, _} -> + {:noreply, + socket + |> assign(:sending_test, false) + |> put_flash(:info, "Test email sent to #{user.email}")} + + {:error, reason} -> + {:noreply, + socket + |> assign(:sending_test, false) + |> put_flash(:error, "Failed to send test email: #{inspect(reason)}")} + end + end + + defp save_adapter_config(socket, adapter_info, params) do + # 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 == "" + # Secret fields can be left blank to keep existing value + empty and not (field.type == :secret and Settings.get_secret("email_#{field.key}") != nil) + end) + + if missing != [] do + labels = Enum.map_join(missing, ", ", & &1.label) + {:noreply, put_flash(socket, :error, "Missing required fields: #{labels}")} + else + # Save adapter type + Settings.put_setting("email_adapter", adapter_info.key) + + # Clear fields from other adapters + current_keys = MapSet.new(Enum.map(adapter_info.fields, &"email_#{&1.key}")) + + for key <- Adapters.all_field_keys(), key not in current_keys do + Settings.delete_setting(key) + end + + # Save current adapter fields (blank secrets keep existing value) + for field <- adapter_info.fields do + value = params[field.key] + settings_key = "email_#{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 + + # Save from address + from_address = params["from_address"] || "" + + if from_address != "" do + Settings.put_setting("email_from_address", from_address) + end + + # 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(:current_values, current_values) + |> assign(:from_address, from_address) + |> assign(:email_configured, Mailer.email_configured?()) + |> put_flash(:info, "Email settings saved")} + end + end + + @impl true + def render(assigns) do + ~H""" +
    + <.header> + Email settings + <:subtitle> + Configure how your shop sends email. Transactional + providers only handle order confirmations and password resets. All email + providers also support newsletters and marketing campaigns. + + + + <%= if @env_locked do %> +
    +
    + <.icon name="hero-lock-closed" class="size-5 text-amber-600 shrink-0 mt-0.5" /> +
    +

    + Controlled by environment variables +

    +

    + Email is configured via SMTP_HOST and related env vars. + Remove them to configure email from this page instead. +

    +
    +
    +
    + <% end %> + +
    + <.form for={@form} phx-change="change_adapter" phx-submit="save"> +
    + <.card_radio_group + name="email[adapter]" + value={@adapter_key} + legend="Email provider" + options={@provider_options} + disabled={@env_locked} + display={:tags} + /> +
    + + <%= for adapter <- @all_adapters do %> + <% selected = @adapter_key == adapter.key %> + + <% end %> + +
    + + <%= if @email_configured do %> +
    +

    Test email

    +

    + Send a test email to {@current_scope.user.email} + to verify delivery works. +

    +
    + +
    +
    + <% end %> +
    + """ + end + + attr :field_def, :map, required: true + attr :value, :any, default: nil + attr :disabled, :boolean, default: false + + defp adapter_field_static(%{field_def: %{type: :secret}} = assigns) do + ~H""" +
    + <.input + name={"email[#{@field_def.key}]"} + value="" + type="password" + label={@field_def.label} + autocomplete="off" + placeholder={if @value, do: @value, else: ""} + required={@field_def.required && !@value} + disabled={@disabled} + /> + <%= if @value && !@disabled do %> +

    + Current: {@value} — leave blank to keep existing value +

    + <% end %> +
    + """ + end + + defp adapter_field_static(%{field_def: %{type: :integer}} = assigns) do + ~H""" + <.input + name={"email[#{@field_def.key}]"} + value={@value || @field_def.default || ""} + type="number" + label={@field_def.label} + required={@field_def.required} + disabled={@disabled} + /> + """ + end + + defp adapter_field_static(assigns) do + ~H""" + <.input + name={"email[#{@field_def.key}]"} + value={@value || @field_def.default || ""} + type="text" + label={@field_def.label} + required={@field_def.required} + disabled={@disabled} + /> + """ + end +end diff --git a/lib/berrypod_web/live/setup/onboarding.ex b/lib/berrypod_web/live/setup/onboarding.ex index 7e7f3d7..583ca3e 100644 --- a/lib/berrypod_web/live/setup/onboarding.ex +++ b/lib/berrypod_web/live/setup/onboarding.ex @@ -101,7 +101,7 @@ defmodule BerrypodWeb.Setup.Onboarding do # ── Events: Provider ── - def handle_event("select_provider", %{"type" => type}, socket) do + def handle_event("select_provider", %{"provider_select" => %{"type" => type}}, socket) do {:noreply, socket |> assign(:selected_provider, type) @@ -363,30 +363,20 @@ defmodule BerrypodWeb.Setup.Onboarding do attr :connecting, :boolean, required: true defp provider_section(assigns) do + assigns = assign(assigns, :provider_options, provider_card_options(assigns.providers)) + ~H"""

    Choose a print-on-demand provider and connect your API key.

    -
    - -
    + <.form for={%{}} as={:provider_select} phx-change="select_provider"> + <.card_radio_group + name="provider_select[type]" + value={@selected} + legend="Print provider" + options={@provider_options} + /> + <%!-- API key form for selected provider --%>
    @@ -548,6 +538,22 @@ defmodule BerrypodWeb.Setup.Onboarding do end end + defp provider_card_options(providers) do + Enum.map(providers, fn provider -> + option = %{ + value: provider.type, + name: provider.name, + description: provider.tagline + } + + if provider.status == :coming_soon do + Map.merge(option, %{badge: "Coming soon", disabled: true}) + else + option + end + end) + end + defp format_error(:unauthorized), do: "That token doesn't seem to be valid" defp format_error(:timeout), do: "Couldn't reach the provider — try again" defp format_error(:provider_not_implemented), do: "This provider isn't supported yet" diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex index e653505..3d93dde 100644 --- a/lib/berrypod_web/router.ex +++ b/lib/berrypod_web/router.ex @@ -176,6 +176,7 @@ defmodule BerrypodWeb.Router do live "/providers/new", Admin.Providers.Form, :new live "/providers/:id/edit", Admin.Providers.Form, :edit live "/settings", Admin.Settings, :index + live "/settings/email", Admin.EmailSettings, :index end # Theme editor: admin root layout but full-screen (no sidebar) diff --git a/test/berrypod/mailer/adapters_test.exs b/test/berrypod/mailer/adapters_test.exs new file mode 100644 index 0000000..8183826 --- /dev/null +++ b/test/berrypod/mailer/adapters_test.exs @@ -0,0 +1,78 @@ +defmodule Berrypod.Mailer.AdaptersTest do + use ExUnit.Case, async: true + + alias Berrypod.Mailer.Adapters + + describe "all/0" do + test "returns a list of adapters" do + adapters = Adapters.all() + assert is_list(adapters) + assert length(adapters) >= 9 + end + + test "each adapter has required keys" do + for adapter <- Adapters.all() do + assert is_binary(adapter.key) + assert is_binary(adapter.name) + assert is_atom(adapter.module) + assert is_binary(adapter.description) + assert is_list(adapter.fields) + assert length(adapter.fields) >= 1 + end + end + + test "each adapter has a url (or nil for SMTP)" do + for adapter <- Adapters.all() do + if adapter.key == "smtp" do + assert is_nil(adapter.url) + else + assert is_binary(adapter.url) + end + end + end + + test "each field has required keys" do + for adapter <- Adapters.all(), field <- adapter.fields do + assert is_binary(field.key) + assert is_binary(field.label) + assert field.type in [:string, :integer, :secret] + end + end + end + + 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") + end + + test "returns nil for unknown key" do + assert is_nil(Adapters.get("unknown")) + end + end + + describe "field_keys/1" do + test "returns settings keys prefixed with email_" do + smtp = Adapters.get("smtp") + keys = Adapters.field_keys(smtp) + + assert "email_relay" in keys + assert "email_port" in keys + assert "email_username" in keys + assert "email_password" in keys + end + end + + describe "all_field_keys/0" do + test "returns unique keys from all adapters" do + keys = Adapters.all_field_keys() + assert is_list(keys) + assert "email_api_key" in keys + assert "email_relay" in keys + assert length(keys) == length(Enum.uniq(keys)) + end + end +end diff --git a/test/berrypod/mailer_test.exs b/test/berrypod/mailer_test.exs new file mode 100644 index 0000000..13259f4 --- /dev/null +++ b/test/berrypod/mailer_test.exs @@ -0,0 +1,79 @@ +defmodule Berrypod.MailerTest do + use Berrypod.DataCase, async: false + + alias Berrypod.Mailer + alias Berrypod.Settings + + setup do + # Store original config to restore after each test + original = Application.get_env(:berrypod, Mailer) + on_exit(fn -> Application.put_env(:berrypod, Mailer, original) end) + :ok + end + + describe "email_configured?/0" do + test "returns false with Local adapter" do + Application.put_env(:berrypod, Mailer, adapter: Swoosh.Adapters.Local) + refute Mailer.email_configured?() + end + + test "returns true with a real adapter" do + Application.put_env(:berrypod, Mailer, adapter: Swoosh.Adapters.Postmark, api_key: "test") + assert Mailer.email_configured?() + end + end + + describe "load_config/0" do + test "loads adapter config from settings" do + Settings.put_setting("email_adapter", "postmark") + Settings.put_secret("email_api_key", "pm_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" + end + + test "loads SMTP config with multiple fields" do + Settings.put_setting("email_adapter", "smtp") + Settings.put_setting("email_relay", "smtp.example.com") + Settings.put_setting("email_port", 465, "integer") + Settings.put_setting("email_username", "user@example.com") + Settings.put_secret("email_password", "secret123") + + Mailer.load_config() + + config = Application.get_env(:berrypod, Mailer) + assert config[:adapter] == Swoosh.Adapters.SMTP + assert config[:relay] == "smtp.example.com" + assert config[:port] == 465 + assert config[:username] == "user@example.com" + assert config[:password] == "secret123" + end + + test "is a no-op when no email_adapter is set" do + original = Application.get_env(:berrypod, Mailer) + Mailer.load_config() + assert Application.get_env(:berrypod, Mailer) == original + end + end + + describe "current_config/0" do + test "returns {nil, %{}} when no adapter configured" do + Application.put_env(:berrypod, Mailer, adapter: Swoosh.Adapters.Local) + assert {nil, %{}} = Mailer.current_config() + end + + test "returns adapter key and config when configured from settings" do + Settings.put_setting("email_adapter", "postmark") + Settings.put_secret("email_api_key", "pm_test_key_123") + + Mailer.load_config() + + {adapter_key, config} = Mailer.current_config() + assert adapter_key == "postmark" + assert config["api_key"] =~ "•••" + 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 new file mode 100644 index 0000000..29a9365 --- /dev/null +++ b/test/berrypod_web/live/admin/email_settings_test.exs @@ -0,0 +1,154 @@ +defmodule BerrypodWeb.Admin.EmailSettingsTest do + use BerrypodWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + import Berrypod.AccountsFixtures + + alias Berrypod.Settings + + setup do + # Ensure mailer starts as test adapter and restore on exit + 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() + %{user: user} + end + + describe "authenticated" do + setup %{conn: conn, user: user} do + conn = log_in_user(conn, user) + %{conn: conn} + end + + test "renders email settings page with provider cards", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/admin/settings/email") + + assert html =~ "Email settings" + assert html =~ "Email provider" + # Provider names rendered as radio cards + assert html =~ "Postmark" + assert html =~ "Brevo" + assert html =~ "Mailjet" + assert html =~ "MailPace" + assert html =~ "Postal" + end + + test "shows provider descriptions", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/admin/settings/email") + + assert html =~ "Excellent deliverability tracking" + assert html =~ "All-in-one platform, GDPR-friendly" + assert html =~ "EU data processing" + end + + test "selecting a provider shows its config fields", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/settings/email") + + # Select SMTP via form change (radio inputs fire phx-change) + html = + view + |> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "smtp"}}) + |> render_change() + + 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 Mailgun which needs api_key + domain + html = + view + |> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "mailgun"}}) + |> render_change() + + assert html =~ "API key" + assert html =~ "Domain" + end + + test "saving config persists settings", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/settings/email") + + # Select Postmark via form change + view + |> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "postmark"}}) + |> render_change() + + # Submit with an API key + html = + view + |> form("form[phx-submit=\"save\"]", %{ + email: %{adapter: "postmark", api_key: "pm_test_123"} + }) + |> render_submit() + + assert html =~ "Email settings saved" + assert Settings.get_setting("email_adapter") == "postmark" + end + + test "saving without required fields shows error", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/settings/email") + + # Select Postmark + view + |> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "postmark"}}) + |> render_change() + + # Submit without API key + html = + view + |> form("form[phx-submit=\"save\"]", %{email: %{adapter: "postmark", api_key: ""}}) + |> render_submit() + + assert html =~ "Missing required fields" + end + + test "disconnect clears email configuration", %{conn: conn} do + Settings.put_setting("email_adapter", "postmark") + Settings.put_secret("email_api_key", "pm_test_abc") + + {:ok, view, _html} = live(conn, ~p"/admin/settings/email") + + html = render_click(view, "disconnect") + + assert html =~ "Email provider disconnected" + assert is_nil(Settings.get_setting("email_adapter")) + end + + test "shows test email section when configured", %{conn: conn} do + Settings.put_setting("email_adapter", "postmark") + Settings.put_secret("email_api_key", "pm_test_abc") + + {:ok, _view, html} = live(conn, ~p"/admin/settings/email") + + assert html =~ "Test email" + assert html =~ "Send test email" + end + + test "hides test email section when not configured", %{conn: conn} do + # Ensure clean state — no adapter configured + Settings.delete_setting("email_adapter") + Application.put_env(:berrypod, Berrypod.Mailer, adapter: Swoosh.Adapters.Local) + + {:ok, _view, html} = live(conn, ~p"/admin/settings/email") + + refute html =~ "Send test email" + end + end + + describe "unauthenticated" do + test "redirects to login", %{conn: conn} do + {:error, redirect} = live(conn, ~p"/admin/settings/email") + assert {:redirect, %{to: path}} = redirect + assert path == ~p"/users/log-in" + end + end +end diff --git a/test/berrypod_web/live/setup/onboarding_test.exs b/test/berrypod_web/live/setup/onboarding_test.exs index 43d80e1..3695eba 100644 --- a/test/berrypod_web/live/setup/onboarding_test.exs +++ b/test/berrypod_web/live/setup/onboarding_test.exs @@ -103,8 +103,8 @@ defmodule BerrypodWeb.Setup.OnboardingTest do html = view - |> element(~s(button[phx-value-type="printify"])) - |> render_click() + |> form(~s(form[phx-change="select_provider"]), %{provider_select: %{type: "printify"}}) + |> render_change() assert html =~ "API token" assert html =~ "Printify"