rework email settings UX with guided flow and friendly errors
grouped providers by category, added per-provider key validation with cross-provider detection, friendly delivery error messages, retryable vs config error distinction, from-address in general settings, and "Save settings" button to match admin conventions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -80,18 +80,21 @@ defmodule Berrypod.KeyValidation do
|
||||
|
||||
# ── Email provider format checks ──
|
||||
|
||||
# Known prefixes for cross-provider detection
|
||||
@known_prefixes [
|
||||
{"SG.", "SendGrid"},
|
||||
{"xkeysib-", "Brevo"},
|
||||
{"re_", "Resend"},
|
||||
{"key-", "Mailgun"},
|
||||
{"mlsn.", "MailerSend"}
|
||||
]
|
||||
|
||||
# SendGrid: SG.{id}.{secret}
|
||||
defp validate_email_format(key, "sendgrid", "api_key") do
|
||||
cond do
|
||||
String.starts_with?(key, "SG.") ->
|
||||
{:ok, key}
|
||||
|
||||
String.contains?(key, ".") ->
|
||||
{:error, "SendGrid keys start with SG. Check you copied the full key"}
|
||||
|
||||
true ->
|
||||
{:error,
|
||||
"SendGrid API keys start with SG. (like SG.xxxxx.xxxxx). This doesn't look right"}
|
||||
String.starts_with?(key, "SG.") -> {:ok, key}
|
||||
wrong = wrong_provider_hint(key, "sendgrid") -> {:error, wrong}
|
||||
true -> {:error, "SendGrid API keys start with SG. — find yours in Settings → API Keys"}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -99,56 +102,147 @@ defmodule Berrypod.KeyValidation do
|
||||
defp validate_email_format(key, "postmark", "api_key") do
|
||||
uuid_pattern = ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
if Regex.match?(uuid_pattern, key) do
|
||||
{:ok, key}
|
||||
else
|
||||
{:error,
|
||||
"Postmark tokens are UUIDs (like abc12345-abcd-1234-abcd-123456789abc). Check you copied the server token, not the account token ID"}
|
||||
cond do
|
||||
Regex.match?(uuid_pattern, key) ->
|
||||
{:ok, key}
|
||||
|
||||
wrong = wrong_provider_hint(key, "postmark") ->
|
||||
{:error, wrong}
|
||||
|
||||
true ->
|
||||
{:error,
|
||||
"Postmark server tokens are UUIDs (like abc12345-abcd-1234-abcd-123456789abc). Make sure you copy the server token, not the account ID"}
|
||||
end
|
||||
end
|
||||
|
||||
# Resend: re_ prefix
|
||||
defp validate_email_format(key, "resend", "api_key") do
|
||||
if String.starts_with?(key, "re_") do
|
||||
{:ok, key}
|
||||
else
|
||||
{:error, "Resend API keys start with re_. Check you copied the full key"}
|
||||
cond do
|
||||
String.starts_with?(key, "re_") ->
|
||||
{:ok, key}
|
||||
|
||||
wrong = wrong_provider_hint(key, "resend") ->
|
||||
{:error, wrong}
|
||||
|
||||
true ->
|
||||
{:error,
|
||||
"Resend API keys start with re_ — find yours in your Resend dashboard under API Keys"}
|
||||
end
|
||||
end
|
||||
|
||||
# Mailgun: key- prefix (classic keys)
|
||||
# Mailgun: key- prefix (classic) or long RBAC keys
|
||||
defp validate_email_format(key, "mailgun", "api_key") do
|
||||
cond do
|
||||
String.starts_with?(key, "key-") ->
|
||||
{:ok, key}
|
||||
|
||||
# Newer RBAC keys don't have a known prefix, let them through
|
||||
String.length(key) >= 20 ->
|
||||
{:ok, key}
|
||||
|
||||
wrong = wrong_provider_hint(key, "mailgun") ->
|
||||
{:error, wrong}
|
||||
|
||||
true ->
|
||||
{:error, "Mailgun API keys usually start with key-. Check you copied the full key"}
|
||||
{:error,
|
||||
"Mailgun API keys usually start with key- — find yours in Settings → API Security"}
|
||||
end
|
||||
end
|
||||
|
||||
# Brevo: xkeysib- prefix
|
||||
defp validate_email_format(key, "brevo", "api_key") do
|
||||
if String.starts_with?(key, "xkeysib-") do
|
||||
{:ok, key}
|
||||
else
|
||||
{:error, "Brevo API keys start with xkeysib-. Check you copied the full key"}
|
||||
cond do
|
||||
String.starts_with?(key, "xkeysib-") ->
|
||||
{:ok, key}
|
||||
|
||||
wrong = wrong_provider_hint(key, "brevo") ->
|
||||
{:error, wrong}
|
||||
|
||||
true ->
|
||||
{:error,
|
||||
"Brevo API keys start with xkeysib- — find yours under SMTP & API in your Brevo account"}
|
||||
end
|
||||
end
|
||||
|
||||
# Unknown provider or non-api_key field, basic length check only
|
||||
# Mailjet: api_key and secret are both 32-char alphanumeric
|
||||
defp validate_email_format(key, "mailjet", "api_key") do
|
||||
cond do
|
||||
String.length(key) >= 20 ->
|
||||
{:ok, key}
|
||||
|
||||
wrong = wrong_provider_hint(key, "mailjet") ->
|
||||
{:error, wrong}
|
||||
|
||||
true ->
|
||||
{:error,
|
||||
"This looks too short for a Mailjet API key — find yours under API Key Management in your Mailjet account"}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_email_format(key, "mailjet", "secret") do
|
||||
cond do
|
||||
String.length(key) >= 20 ->
|
||||
{:ok, key}
|
||||
|
||||
wrong = wrong_provider_hint(key, "mailjet") ->
|
||||
{:error, wrong}
|
||||
|
||||
true ->
|
||||
{:error,
|
||||
"This looks too short for a Mailjet secret key — it's shown alongside your API key in API Key Management"}
|
||||
end
|
||||
end
|
||||
|
||||
# MailerSend: mlsn. prefix
|
||||
defp validate_email_format(key, "mailersend", "api_key") do
|
||||
cond do
|
||||
String.starts_with?(key, "mlsn.") ->
|
||||
{:ok, key}
|
||||
|
||||
String.length(key) >= 40 ->
|
||||
{:ok, key}
|
||||
|
||||
wrong = wrong_provider_hint(key, "mailersend") ->
|
||||
{:error, wrong}
|
||||
|
||||
true ->
|
||||
{:error,
|
||||
"MailerSend API tokens usually start with mlsn. — generate one under Domains → Manage → API Tokens"}
|
||||
end
|
||||
end
|
||||
|
||||
# MailPace: server token, no known prefix
|
||||
defp validate_email_format(key, "mailpace", "api_key") do
|
||||
cond do
|
||||
String.length(key) >= 10 ->
|
||||
{:ok, key}
|
||||
|
||||
wrong = wrong_provider_hint(key, "mailpace") ->
|
||||
{:error, wrong}
|
||||
|
||||
true ->
|
||||
{:error,
|
||||
"This looks too short — find your server API token under your domain settings in MailPace"}
|
||||
end
|
||||
end
|
||||
|
||||
# Non-api_key fields (domain, relay, base_url, etc.), basic checks
|
||||
defp validate_email_format(key, _adapter_key, _field_key) do
|
||||
if String.length(key) < 5 do
|
||||
{:error, "This value looks too short. Check you copied the full key"}
|
||||
if String.length(key) < 3 do
|
||||
{:error, "This value looks too short"}
|
||||
else
|
||||
{:ok, key}
|
||||
end
|
||||
end
|
||||
|
||||
# Detects when a key meant for another provider is pasted into the wrong field
|
||||
defp wrong_provider_hint(key, current_adapter) do
|
||||
Enum.find_value(@known_prefixes, fn {prefix, name} ->
|
||||
if String.starts_with?(key, prefix) and current_adapter != String.downcase(name) do
|
||||
"This looks like a #{name} key, not the right one for this provider"
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
defp trim(nil), do: ""
|
||||
|
||||
@@ -132,6 +132,101 @@ defmodule Berrypod.Mailer do
|
||||
Settings.get_setting("email_from_address") || "noreply@#{default_from_domain()}"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Turns a raw delivery error into a user-friendly message.
|
||||
|
||||
Swoosh adapters return varied error shapes — HTTP status tuples,
|
||||
SMTP failure atoms, network errors, etc. This normalises them into
|
||||
a single readable string for the admin UI.
|
||||
"""
|
||||
def friendly_error({status, body}) when is_integer(status) do
|
||||
message = extract_message(body)
|
||||
|
||||
case status do
|
||||
401 ->
|
||||
"Your API key was rejected" <>
|
||||
if(message, do: " — #{message}", else: ". Double-check you pasted the right key")
|
||||
|
||||
403 ->
|
||||
"Your API key doesn't have permission to send email" <>
|
||||
if(message, do: " — #{message}", else: "")
|
||||
|
||||
404 ->
|
||||
"Provider endpoint not found" <>
|
||||
if(message, do: " — #{message}", else: ". Check your config")
|
||||
|
||||
422 ->
|
||||
"The provider rejected the request" <> if(message, do: " — #{message}", else: "")
|
||||
|
||||
429 ->
|
||||
"Rate limit reached. Wait a moment and try again"
|
||||
|
||||
status when status >= 500 ->
|
||||
"The email provider is having issues (#{status}). Try again in a few minutes"
|
||||
|
||||
_ ->
|
||||
"Provider returned error #{status}" <> if(message, do: " — #{message}", else: "")
|
||||
end
|
||||
end
|
||||
|
||||
# SMTP errors: {:permanent_failure, message} or {:temporary_failure, message}
|
||||
def friendly_error({:permanent_failure, msg}) when is_binary(msg),
|
||||
do: "SMTP server rejected the email: #{truncate(msg)}"
|
||||
|
||||
def friendly_error({:temporary_failure, msg}) when is_binary(msg),
|
||||
do: "SMTP server temporarily unavailable: #{truncate(msg)}"
|
||||
|
||||
def friendly_error({:retries_exceeded, _}),
|
||||
do: "SMTP connection failed after retries. Check your server address and port"
|
||||
|
||||
# SMTP connection errors
|
||||
def friendly_error({:no_more_hosts, _}),
|
||||
do: "Couldn't connect to the SMTP server. Check the hostname and port"
|
||||
|
||||
def friendly_error({:auth_failed, _}), do: "SMTP login failed. Check your username and password"
|
||||
|
||||
# Network-level errors
|
||||
def friendly_error(:timeout), do: "Connection timed out. Check your internet connection"
|
||||
def friendly_error(:econnrefused), do: "Connection refused. Check the server address and port"
|
||||
def friendly_error(:nxdomain), do: "Server hostname not found. Check the address"
|
||||
def friendly_error(:closed), do: "Connection was closed. The server may be down"
|
||||
|
||||
# Catch-all
|
||||
def friendly_error(other) when is_binary(other), do: other
|
||||
def friendly_error(other), do: "Delivery failed: #{inspect(other)}"
|
||||
|
||||
@doc """
|
||||
Returns whether a delivery error is transient (worth retrying).
|
||||
|
||||
Config errors (bad key, wrong permissions) need a fix, not a retry.
|
||||
Transient errors (rate limits, timeouts, server issues) may resolve on their own.
|
||||
"""
|
||||
def retryable_error?({status, _}) when is_integer(status), do: status in [429] or status >= 500
|
||||
def retryable_error?({:temporary_failure, _}), do: true
|
||||
def retryable_error?({:retries_exceeded, _}), do: true
|
||||
def retryable_error?(:timeout), do: true
|
||||
def retryable_error?(:closed), do: true
|
||||
def retryable_error?(_), do: false
|
||||
|
||||
# Extract a human-readable message from various API error body shapes
|
||||
defp extract_message(%{"message" => msg}) when is_binary(msg), do: downcase_first(msg)
|
||||
defp extract_message(%{"ErrorMessage" => msg}) when is_binary(msg), do: downcase_first(msg)
|
||||
|
||||
defp extract_message(%{"error" => %{"message" => msg}}) when is_binary(msg),
|
||||
do: downcase_first(msg)
|
||||
|
||||
defp extract_message(%{"errors" => [%{"message" => msg} | _]}) when is_binary(msg),
|
||||
do: downcase_first(msg)
|
||||
|
||||
defp extract_message(body) when is_binary(body) and byte_size(body) < 200, do: body
|
||||
defp extract_message(_), do: nil
|
||||
|
||||
defp downcase_first(<<first::utf8, rest::binary>>), do: String.downcase(<<first::utf8>>) <> rest
|
||||
defp downcase_first(str), do: str
|
||||
|
||||
defp truncate(msg) when byte_size(msg) > 120, do: binary_part(msg, 0, 120) <> "..."
|
||||
defp truncate(msg), do: msg
|
||||
|
||||
# Build Swoosh config keyword list from Settings for a given adapter
|
||||
defp build_config(adapter_info) do
|
||||
opts =
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
defmodule Berrypod.Mailer.Adapter do
|
||||
@moduledoc false
|
||||
@enforce_keys [:key, :name, :module, :description, :tags, :fields]
|
||||
defstruct [:key, :name, :module, :description, :tags, :fields, url: nil]
|
||||
@enforce_keys [:key, :name, :module, :description, :fields]
|
||||
defstruct [
|
||||
:key,
|
||||
:name,
|
||||
:module,
|
||||
:description,
|
||||
:fields,
|
||||
:free_tier,
|
||||
:setup_hint,
|
||||
tags: [],
|
||||
url: nil,
|
||||
category: :all_email,
|
||||
recommended: false
|
||||
]
|
||||
end
|
||||
|
||||
@@ -9,20 +9,21 @@ defmodule Berrypod.Mailer.Adapters do
|
||||
|
||||
alias Berrypod.Mailer.{Adapter, Field}
|
||||
|
||||
# Ordered by capability: transactional + marketing first,
|
||||
# then transactional only, then self-hosted.
|
||||
@adapters [
|
||||
# ── Also sends newsletters ──
|
||||
%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"],
|
||||
key: "brevo",
|
||||
name: "Brevo",
|
||||
module: Swoosh.Adapters.Brevo,
|
||||
description: "All-in-one platform, GDPR-friendly.",
|
||||
tags: ["All email", "France", "GDPR"],
|
||||
category: :all_email,
|
||||
recommended: true,
|
||||
free_tier: "300 emails/day free",
|
||||
setup_hint: "Paste one API key",
|
||||
url: "https://www.brevo.com",
|
||||
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}
|
||||
%Field{key: "api_key", label: "API key", type: :secret, required: true}
|
||||
]
|
||||
},
|
||||
%Adapter{
|
||||
@@ -31,28 +32,23 @@ defmodule Berrypod.Mailer.Adapters do
|
||||
module: Swoosh.Adapters.Sendgrid,
|
||||
description: "Generous free tier, widely used.",
|
||||
tags: ["All email", "US"],
|
||||
category: :all_email,
|
||||
free_tier: "100 emails/day free",
|
||||
setup_hint: "Paste one API key",
|
||||
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"],
|
||||
category: :all_email,
|
||||
free_tier: "200 emails/day free",
|
||||
setup_hint: "Two API keys needed",
|
||||
url: "https://www.mailjet.com",
|
||||
fields: [
|
||||
%Field{key: "api_key", label: "API key", type: :secret, required: true},
|
||||
@@ -65,62 +61,97 @@ defmodule Berrypod.Mailer.Adapters do
|
||||
module: Swoosh.Adapters.MailerSend,
|
||||
description: "Generous free tier, good analytics dashboard.",
|
||||
tags: ["All email", "EU option"],
|
||||
category: :all_email,
|
||||
free_tier: "3,000 emails/month free",
|
||||
setup_hint: "Paste one API key",
|
||||
url: "https://www.mailersend.com",
|
||||
fields: [
|
||||
%Field{key: "api_key", label: "API key", type: :secret, required: true}
|
||||
]
|
||||
},
|
||||
# ── Transactional only ──
|
||||
%Adapter{
|
||||
key: "resend",
|
||||
name: "Resend",
|
||||
module: Swoosh.Adapters.Resend,
|
||||
description: "Developer-friendly API, simple setup.",
|
||||
tags: ["Transactional", "US"],
|
||||
category: :transactional,
|
||||
free_tier: "3,000 emails/month free",
|
||||
setup_hint: "Paste one API key",
|
||||
url: "https://resend.com",
|
||||
fields: [
|
||||
%Field{key: "api_key", label: "API key", type: :secret, required: true}
|
||||
]
|
||||
},
|
||||
%Adapter{
|
||||
key: "postmark",
|
||||
name: "Postmark",
|
||||
module: Swoosh.Adapters.Postmark,
|
||||
description: "Excellent deliverability tracking.",
|
||||
tags: ["Transactional", "US"],
|
||||
category: :transactional,
|
||||
free_tier: "100 emails/month free",
|
||||
setup_hint: "Paste one API key",
|
||||
url: "https://postmarkapp.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"],
|
||||
category: :transactional,
|
||||
free_tier: "100 emails/day trial",
|
||||
setup_hint: "API key + domain name",
|
||||
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"],
|
||||
category: :transactional,
|
||||
free_tier: "3,000 emails/month free",
|
||||
setup_hint: "Paste one API key",
|
||||
url: "https://mailpace.com",
|
||||
fields: [
|
||||
%Field{key: "api_key", label: "API key", type: :secret, required: true}
|
||||
]
|
||||
},
|
||||
# ── Advanced ──
|
||||
%Adapter{
|
||||
key: "smtp",
|
||||
name: "SMTP",
|
||||
module: Swoosh.Adapters.SMTP,
|
||||
description: "Connect to any email server via SMTP.",
|
||||
tags: ["All email", "Any provider"],
|
||||
category: :advanced,
|
||||
setup_hint: "Server details needed",
|
||||
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: "postal",
|
||||
name: "Postal",
|
||||
module: Swoosh.Adapters.Postal,
|
||||
description: "Full control over your email infrastructure.",
|
||||
tags: ["All email", "Self-hosted", "Open source"],
|
||||
category: :advanced,
|
||||
free_tier: "Self-hosted, no limits",
|
||||
setup_hint: "Requires your own server",
|
||||
url: "https://docs.postalserver.io",
|
||||
fields: [
|
||||
%Field{key: "api_key", label: "API key", type: :secret, required: true},
|
||||
@@ -132,6 +163,11 @@ defmodule Berrypod.Mailer.Adapters do
|
||||
@doc "Returns all supported adapters."
|
||||
def all, do: @adapters
|
||||
|
||||
@doc "Returns adapters grouped by category."
|
||||
def grouped do
|
||||
Enum.group_by(@adapters, & &1.category)
|
||||
end
|
||||
|
||||
@doc "Returns an adapter by its string key, or nil."
|
||||
def get(key) when is_binary(key) do
|
||||
Enum.find(@adapters, &(&1.key == key))
|
||||
|
||||
Reference in New Issue
Block a user