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}
+
+
+
+ {option.name}
+ <.card_radio_content option={option} display={@display} />
+
+
+
+ """
+ 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 %>
+
+
+
+
{adapter.description}
+
+ <.input
+ name="email[from_address]"
+ value={@from_address}
+ type="email"
+ label="From address"
+ placeholder="noreply@yourshop.com"
+ disabled={!selected || @env_locked}
+ />
+ <%= for field <- adapter.fields do %>
+ <.adapter_field_static
+ field_def={field}
+ value={if selected, do: @current_values[field.key]}
+ disabled={!selected || @env_locked}
+ />
+ <% end %>
+ <%= unless @env_locked do %>
+
+ <.button phx-disable-with="Saving..." disabled={!selected}>
+ Save settings
+
+ <%= if selected && @email_configured do %>
+
+ Disconnect
+
+ <% end %>
+
+ <% end %>
+
+ <% end %>
+
+
+
+ <%= if @email_configured do %>
+
+ Test email
+
+ Send a test email to {@current_scope.user.email}
+ to verify delivery works.
+
+
+
+ <.icon name="hero-paper-airplane" class="size-4" />
+ {if @sending_test, do: "Sending...", else: "Send test email"}
+
+
+
+ <% 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.
-
-
- {provider.name}
- {provider.tagline}
-
- Coming soon
-
-
-
+ <.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"