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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user