add admin email settings page with provider selection
All checks were successful
deploy / deploy (push) Successful in 56s
All checks were successful
deploy / deploy (push) Successful in 56s
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 <noreply@anthropic.com>
This commit is contained in:
parent
a2e46664c6
commit
366a1e6a48
@ -818,15 +818,21 @@
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Provider picker grid */
|
/* Card radio group — selectable cards backed by radio inputs */
|
||||||
.setup-provider-grid {
|
.card-radio-fieldset {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-radio-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 1rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setup-provider-card {
|
.card-radio-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@ -834,36 +840,48 @@
|
|||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid var(--t-border-default, #d4d4d4);
|
border: 1px solid var(--t-border-default, #d4d4d4);
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 150ms, background 150ms;
|
transition: border-color 150ms, background 150ms;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
@media (hover: hover) {
|
||||||
border-color: var(--t-text-primary, #171717);
|
&:hover:not(:has(:disabled)) {
|
||||||
|
border-color: color-mix(in oklch, var(--t-text-primary) 40%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.setup-provider-card-selected {
|
&: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);
|
border-color: var(--t-text-primary, #171717);
|
||||||
background: var(--t-surface-sunken, #e5e5e5);
|
background: var(--t-surface-sunken, #e5e5e5);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.setup-provider-card-disabled {
|
.card-radio-card-disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setup-provider-name {
|
.card-radio-input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-radio-name {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setup-provider-tagline {
|
.card-radio-description {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: color-mix(in oklch, var(--t-text-primary) 60%, transparent);
|
color: color-mix(in oklch, var(--t-text-primary) 60%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.setup-provider-badge {
|
.card-radio-badge {
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
padding: 0.125rem 0.375rem;
|
padding: 0.125rem 0.375rem;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
@ -871,6 +889,34 @@
|
|||||||
color: color-mix(in oklch, var(--t-text-primary) 60%, transparent);
|
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 {
|
.setup-provider-form {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||||
const liveSocket = new LiveSocket("/live", Socket, {
|
const liveSocket = new LiveSocket("/live", Socket, {
|
||||||
params: {_csrf_token: csrfToken},
|
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
|
// Show progress bar on live navigation and form submits
|
||||||
|
|||||||
@ -16,6 +16,8 @@ defmodule Berrypod.Application do
|
|||||||
Supervisor.child_spec({Task, &Berrypod.Release.seed_defaults/0}, id: :seed_defaults),
|
Supervisor.child_spec({Task, &Berrypod.Release.seed_defaults/0}, id: :seed_defaults),
|
||||||
# Load encrypted secrets from DB into Application env
|
# Load encrypted secrets from DB into Application env
|
||||||
{Task, &Berrypod.Secrets.load_all/0},
|
{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},
|
{DNSCluster, query: Application.get_env(:berrypod, :dns_cluster_query) || :ignore},
|
||||||
{Phoenix.PubSub, name: Berrypod.PubSub},
|
{Phoenix.PubSub, name: Berrypod.PubSub},
|
||||||
# Background job processing
|
# Background job processing
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
defmodule Berrypod.Mailer do
|
defmodule Berrypod.Mailer do
|
||||||
use Swoosh.Mailer, otp_app: :berrypod
|
use Swoosh.Mailer, otp_app: :berrypod
|
||||||
|
|
||||||
|
alias Berrypod.Mailer.Adapters
|
||||||
|
alias Berrypod.Settings
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns whether a real email adapter is configured.
|
Returns whether a real email adapter is configured.
|
||||||
|
|
||||||
@ -11,4 +14,137 @@ defmodule Berrypod.Mailer do
|
|||||||
adapter = Application.get_env(:berrypod, __MODULE__)[:adapter]
|
adapter = Application.get_env(:berrypod, __MODULE__)[:adapter]
|
||||||
adapter != nil and adapter != Swoosh.Adapters.Local
|
adapter != nil and adapter != Swoosh.Adapters.Local
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
5
lib/berrypod/mailer/adapter.ex
Normal file
5
lib/berrypod/mailer/adapter.ex
Normal file
@ -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
|
||||||
140
lib/berrypod/mailer/adapters.ex
Normal file
140
lib/berrypod/mailer/adapters.ex
Normal file
@ -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
|
||||||
5
lib/berrypod/mailer/field.ex
Normal file
5
lib/berrypod/mailer/field.ex
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
defmodule Berrypod.Mailer.Field do
|
||||||
|
@moduledoc false
|
||||||
|
@enforce_keys [:key, :label, :type]
|
||||||
|
defstruct [:key, :label, :type, :default, required: false]
|
||||||
|
end
|
||||||
@ -519,6 +519,99 @@ defmodule BerrypodWeb.CoreComponents do
|
|||||||
"""
|
"""
|
||||||
end
|
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"""
|
||||||
|
<fieldset class="card-radio-fieldset" disabled={@disabled}>
|
||||||
|
<legend class="admin-label">{@legend}</legend>
|
||||||
|
<div class="card-radio-grid">
|
||||||
|
<label
|
||||||
|
:for={option <- @options}
|
||||||
|
class={[
|
||||||
|
"card-radio-card",
|
||||||
|
@value == option.value && "card-radio-card-selected",
|
||||||
|
option[:disabled] && "card-radio-card-disabled"
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id={"#{@name}-#{option.value}"}
|
||||||
|
name={@name}
|
||||||
|
value={option.value}
|
||||||
|
checked={@value == option.value}
|
||||||
|
disabled={option[:disabled] || @disabled}
|
||||||
|
class="card-radio-input"
|
||||||
|
/>
|
||||||
|
<span class="card-radio-name">{option.name}</span>
|
||||||
|
<.card_radio_content option={option} display={@display} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
attr :option, :map, required: true
|
||||||
|
attr :display, :atom, required: true
|
||||||
|
|
||||||
|
defp card_radio_content(%{display: :tags} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<span :if={@option[:tags]} class="card-radio-tags">
|
||||||
|
<span :for={tag <- @option.tags} class="card-radio-tag">{tag}</span>
|
||||||
|
</span>
|
||||||
|
<span :if={@option[:description]} class="card-radio-description">
|
||||||
|
{@option.description}
|
||||||
|
</span>
|
||||||
|
<span :if={@option[:badge]} class="card-radio-badge">{@option.badge}</span>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp card_radio_content(assigns) do
|
||||||
|
~H"""
|
||||||
|
<span :if={@option[:description]} class="card-radio-description">
|
||||||
|
{@option.description}
|
||||||
|
</span>
|
||||||
|
<span :if={@option[:badge]} class="card-radio-badge">{@option.badge}</span>
|
||||||
|
<a
|
||||||
|
:if={@option[:url]}
|
||||||
|
href={@option.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="card-radio-link"
|
||||||
|
onclick="event.stopPropagation();"
|
||||||
|
>
|
||||||
|
{@option.name} ↗
|
||||||
|
</a>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
def show_modal(js \\ %JS{}, id) when is_binary(id) do
|
def show_modal(js \\ %JS{}, id) when is_binary(id) do
|
||||||
js
|
js
|
||||||
|> JS.exec("showModal()", to: "##{id}")
|
|> JS.exec("showModal()", to: "##{id}")
|
||||||
|
|||||||
@ -50,6 +50,10 @@ defmodule BerrypodWeb.Layouts do
|
|||||||
if current_path == "/admin", do: "active", else: nil
|
if current_path == "/admin", do: "active", else: nil
|
||||||
end
|
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
|
def admin_nav_active?(current_path, link_path) do
|
||||||
if String.starts_with?(current_path, link_path), do: "active", else: nil
|
if String.starts_with?(current_path, link_path), do: "active", else: nil
|
||||||
end
|
end
|
||||||
|
|||||||
@ -23,6 +23,9 @@
|
|||||||
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
|
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
|
||||||
<p>
|
<p>
|
||||||
Email delivery isn't set up yet — customers won't receive order confirmations or shipping updates.
|
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
|
||||||
|
</.link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -99,6 +102,14 @@
|
|||||||
<.icon name="hero-cog-6-tooth" class="size-5" /> Settings
|
<.icon name="hero-cog-6-tooth" class="size-5" /> Settings
|
||||||
</.link>
|
</.link>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<.link
|
||||||
|
navigate={~p"/admin/settings/email"}
|
||||||
|
class={admin_nav_active?(@current_path, "/admin/settings/email")}
|
||||||
|
>
|
||||||
|
<.icon name="hero-envelope" class="size-5" /> Email
|
||||||
|
</.link>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
355
lib/berrypod_web/live/admin/email_settings.ex
Normal file
355
lib/berrypod_web/live/admin/email_settings.ex
Normal file
@ -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"""
|
||||||
|
<div class="max-w-2xl">
|
||||||
|
<.header>
|
||||||
|
Email settings
|
||||||
|
<:subtitle>
|
||||||
|
Configure how your shop sends email. <strong>Transactional</strong>
|
||||||
|
providers only handle order confirmations and password resets. <strong>All email</strong>
|
||||||
|
providers also support newsletters and marketing campaigns.
|
||||||
|
</:subtitle>
|
||||||
|
</.header>
|
||||||
|
|
||||||
|
<%= if @env_locked do %>
|
||||||
|
<div class="mt-6 rounded-md bg-amber-50 p-4 ring-1 ring-amber-600/10 ring-inset">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<.icon name="hero-lock-closed" class="size-5 text-amber-600 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-amber-800">
|
||||||
|
Controlled by environment variables
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm text-amber-700">
|
||||||
|
Email is configured via <code>SMTP_HOST</code> and related env vars.
|
||||||
|
Remove them to configure email from this page instead.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<section class="mt-8">
|
||||||
|
<.form for={@form} phx-change="change_adapter" phx-submit="save">
|
||||||
|
<div id="email-provider-cards" phx-hook="CardRadioScroll">
|
||||||
|
<.card_radio_group
|
||||||
|
name="email[adapter]"
|
||||||
|
value={@adapter_key}
|
||||||
|
legend="Email provider"
|
||||||
|
options={@provider_options}
|
||||||
|
disabled={@env_locked}
|
||||||
|
display={:tags}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= for adapter <- @all_adapters do %>
|
||||||
|
<% selected = @adapter_key == adapter.key %>
|
||||||
|
<div
|
||||||
|
id={"adapter-config-#{adapter.key}"}
|
||||||
|
class="mt-6 space-y-4"
|
||||||
|
hidden={!selected}
|
||||||
|
data-adapter={adapter.key}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold">
|
||||||
|
{adapter.name}
|
||||||
|
<a
|
||||||
|
:if={adapter.url}
|
||||||
|
href={adapter.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="text-sm font-normal text-base-content/50 hover:text-base-content/80"
|
||||||
|
>
|
||||||
|
↗
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-base-content/60">{adapter.description}</p>
|
||||||
|
</div>
|
||||||
|
<.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 %>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<.button phx-disable-with="Saving..." disabled={!selected}>
|
||||||
|
Save settings
|
||||||
|
</.button>
|
||||||
|
<%= if selected && @email_configured do %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="disconnect"
|
||||||
|
data-confirm="Remove email configuration? Transactional emails will stop being sent."
|
||||||
|
class="text-sm text-red-600 hover:text-red-800"
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</.form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<%= if @email_configured do %>
|
||||||
|
<section class="mt-8 border-t border-base-200 pt-6">
|
||||||
|
<h2 class="text-lg font-semibold">Test email</h2>
|
||||||
|
<p class="mt-1 text-sm text-base-content/60">
|
||||||
|
Send a test email to <strong>{@current_scope.user.email}</strong>
|
||||||
|
to verify delivery works.
|
||||||
|
</p>
|
||||||
|
<div class="mt-4">
|
||||||
|
<button
|
||||||
|
phx-click="send_test"
|
||||||
|
disabled={@sending_test}
|
||||||
|
class="inline-flex items-center gap-2 rounded-md bg-base-200 px-3 py-2 text-sm font-medium text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset"
|
||||||
|
>
|
||||||
|
<.icon name="hero-paper-airplane" class="size-4" />
|
||||||
|
{if @sending_test, do: "Sending...", else: "Send test email"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
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"""
|
||||||
|
<div>
|
||||||
|
<.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 %>
|
||||||
|
<p class="text-xs text-base-content/60 mt-1">
|
||||||
|
Current: <code>{@value}</code> — leave blank to keep existing value
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
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
|
||||||
@ -101,7 +101,7 @@ defmodule BerrypodWeb.Setup.Onboarding do
|
|||||||
|
|
||||||
# ── Events: Provider ──
|
# ── Events: Provider ──
|
||||||
|
|
||||||
def handle_event("select_provider", %{"type" => type}, socket) do
|
def handle_event("select_provider", %{"provider_select" => %{"type" => type}}, socket) do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:selected_provider, type)
|
|> assign(:selected_provider, type)
|
||||||
@ -363,30 +363,20 @@ defmodule BerrypodWeb.Setup.Onboarding do
|
|||||||
attr :connecting, :boolean, required: true
|
attr :connecting, :boolean, required: true
|
||||||
|
|
||||||
defp provider_section(assigns) do
|
defp provider_section(assigns) do
|
||||||
|
assigns = assign(assigns, :provider_options, provider_card_options(assigns.providers))
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div>
|
<div>
|
||||||
<p class="setup-hint">Choose a print-on-demand provider and connect your API key.</p>
|
<p class="setup-hint">Choose a print-on-demand provider and connect your API key.</p>
|
||||||
|
|
||||||
<div class="setup-provider-grid">
|
<.form for={%{}} as={:provider_select} phx-change="select_provider">
|
||||||
<button
|
<.card_radio_group
|
||||||
:for={provider <- @providers}
|
name="provider_select[type]"
|
||||||
type="button"
|
value={@selected}
|
||||||
phx-click={provider.status == :available && "select_provider"}
|
legend="Print provider"
|
||||||
phx-value-type={provider.type}
|
options={@provider_options}
|
||||||
disabled={provider.status == :coming_soon}
|
/>
|
||||||
class={[
|
</.form>
|
||||||
"setup-provider-card",
|
|
||||||
@selected == provider.type && "setup-provider-card-selected",
|
|
||||||
provider.status == :coming_soon && "setup-provider-card-disabled"
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<span class="setup-provider-name">{provider.name}</span>
|
|
||||||
<span class="setup-provider-tagline">{provider.tagline}</span>
|
|
||||||
<span :if={provider.status == :coming_soon} class="setup-provider-badge">
|
|
||||||
Coming soon
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- API key form for selected provider --%>
|
<%!-- API key form for selected provider --%>
|
||||||
<div :if={@selected} class="setup-provider-form">
|
<div :if={@selected} class="setup-provider-form">
|
||||||
@ -548,6 +538,22 @@ defmodule BerrypodWeb.Setup.Onboarding do
|
|||||||
end
|
end
|
||||||
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(: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(:timeout), do: "Couldn't reach the provider — try again"
|
||||||
defp format_error(:provider_not_implemented), do: "This provider isn't supported yet"
|
defp format_error(:provider_not_implemented), do: "This provider isn't supported yet"
|
||||||
|
|||||||
@ -176,6 +176,7 @@ defmodule BerrypodWeb.Router do
|
|||||||
live "/providers/new", Admin.Providers.Form, :new
|
live "/providers/new", Admin.Providers.Form, :new
|
||||||
live "/providers/:id/edit", Admin.Providers.Form, :edit
|
live "/providers/:id/edit", Admin.Providers.Form, :edit
|
||||||
live "/settings", Admin.Settings, :index
|
live "/settings", Admin.Settings, :index
|
||||||
|
live "/settings/email", Admin.EmailSettings, :index
|
||||||
end
|
end
|
||||||
|
|
||||||
# Theme editor: admin root layout but full-screen (no sidebar)
|
# Theme editor: admin root layout but full-screen (no sidebar)
|
||||||
|
|||||||
78
test/berrypod/mailer/adapters_test.exs
Normal file
78
test/berrypod/mailer/adapters_test.exs
Normal file
@ -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
|
||||||
79
test/berrypod/mailer_test.exs
Normal file
79
test/berrypod/mailer_test.exs
Normal file
@ -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
|
||||||
154
test/berrypod_web/live/admin/email_settings_test.exs
Normal file
154
test/berrypod_web/live/admin/email_settings_test.exs
Normal file
@ -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
|
||||||
@ -103,8 +103,8 @@ defmodule BerrypodWeb.Setup.OnboardingTest do
|
|||||||
|
|
||||||
html =
|
html =
|
||||||
view
|
view
|
||||||
|> element(~s(button[phx-value-type="printify"]))
|
|> form(~s(form[phx-change="select_provider"]), %{provider_select: %{type: "printify"}})
|
||||||
|> render_click()
|
|> render_change()
|
||||||
|
|
||||||
assert html =~ "API token"
|
assert html =~ "API token"
|
||||||
assert html =~ "Printify"
|
assert html =~ "Printify"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user