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:
parent
67a26eb6b4
commit
7547d0d4b8
@ -1277,11 +1277,27 @@
|
|||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-toggle-indicator {
|
||||||
|
position: absolute;
|
||||||
|
width: 33.333%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--t-surface-raised, #fff);
|
||||||
|
left: 0;
|
||||||
|
transition: left 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
.theme-toggle-btn {
|
.theme-toggle-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 33.333%;
|
width: 33.333%;
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0.6;
|
||||||
|
|
||||||
|
&[aria-pressed="true"] {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Setup page ── */
|
/* ── Setup page ── */
|
||||||
@ -1290,11 +1306,26 @@
|
|||||||
max-width: 36rem;
|
max-width: 36rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setup-header {
|
.setup-header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-footer {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
margin-top: -0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setup-title {
|
.setup-title {
|
||||||
@ -4206,23 +4237,120 @@
|
|||||||
padding-top: 1.5rem;
|
padding-top: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Email provider selection ── */
|
||||||
|
|
||||||
|
.admin-provider-group-desc {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-provider-other {
|
||||||
|
margin-top: 1rem;
|
||||||
|
|
||||||
|
& > .card-radio-grid {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-provider-other-toggle {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--admin-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "\25B8 ";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-provider-other[open] > .admin-provider-other-toggle::before {
|
||||||
|
content: "\25BE ";
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-radio-recommended {
|
||||||
|
background: var(--admin-accent, oklch(0.65 0.2 145));
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Numbered setup steps ── */
|
||||||
|
|
||||||
|
.admin-setup-step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-setup-step-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-setup-step-number {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--admin-accent, oklch(0.65 0.2 145));
|
||||||
|
color: white;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-setup-step-title {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--admin-text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-setup-step-desc {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--admin-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-setup-step-number-done {
|
||||||
|
background: oklch(0.55 0.15 145);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-setup-step-number-error {
|
||||||
|
background: var(--t-status-error, oklch(0.6 0.2 25));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-test-error {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--t-status-error, oklch(0.6 0.2 25));
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-btn-error {
|
||||||
|
border-color: var(--t-status-error, oklch(0.6 0.2 25));
|
||||||
|
color: var(--t-status-error, oklch(0.6 0.2 25));
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Email adapter config ── */
|
/* ── Email adapter config ── */
|
||||||
|
|
||||||
.admin-adapter-config {
|
.admin-adapter-config {
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1.5rem;
|
||||||
|
|
||||||
&[hidden] {
|
&[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-adapter-link {
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Campaign form ── */
|
/* ── Campaign form ── */
|
||||||
|
|
||||||
.admin-campaign-actions {
|
.admin-campaign-actions {
|
||||||
|
|||||||
@ -80,18 +80,21 @@ defmodule Berrypod.KeyValidation do
|
|||||||
|
|
||||||
# ── Email provider format checks ──
|
# ── 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}
|
# SendGrid: SG.{id}.{secret}
|
||||||
defp validate_email_format(key, "sendgrid", "api_key") do
|
defp validate_email_format(key, "sendgrid", "api_key") do
|
||||||
cond do
|
cond do
|
||||||
String.starts_with?(key, "SG.") ->
|
String.starts_with?(key, "SG.") -> {:ok, key}
|
||||||
{:ok, key}
|
wrong = wrong_provider_hint(key, "sendgrid") -> {:error, wrong}
|
||||||
|
true -> {:error, "SendGrid API keys start with SG. — find yours in Settings → API Keys"}
|
||||||
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"}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -99,56 +102,147 @@ defmodule Berrypod.KeyValidation do
|
|||||||
defp validate_email_format(key, "postmark", "api_key") 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
|
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
|
cond do
|
||||||
{:ok, key}
|
Regex.match?(uuid_pattern, key) ->
|
||||||
else
|
{:ok, key}
|
||||||
{:error,
|
|
||||||
"Postmark tokens are UUIDs (like abc12345-abcd-1234-abcd-123456789abc). Check you copied the server token, not the account token ID"}
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
# Resend: re_ prefix
|
# Resend: re_ prefix
|
||||||
defp validate_email_format(key, "resend", "api_key") do
|
defp validate_email_format(key, "resend", "api_key") do
|
||||||
if String.starts_with?(key, "re_") do
|
cond do
|
||||||
{:ok, key}
|
String.starts_with?(key, "re_") ->
|
||||||
else
|
{:ok, key}
|
||||||
{:error, "Resend API keys start with re_. Check you copied the full 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
|
||||||
end
|
end
|
||||||
|
|
||||||
# Mailgun: key- prefix (classic keys)
|
# Mailgun: key- prefix (classic) or long RBAC keys
|
||||||
defp validate_email_format(key, "mailgun", "api_key") do
|
defp validate_email_format(key, "mailgun", "api_key") do
|
||||||
cond do
|
cond do
|
||||||
String.starts_with?(key, "key-") ->
|
String.starts_with?(key, "key-") ->
|
||||||
{:ok, key}
|
{:ok, key}
|
||||||
|
|
||||||
# Newer RBAC keys don't have a known prefix, let them through
|
|
||||||
String.length(key) >= 20 ->
|
String.length(key) >= 20 ->
|
||||||
{:ok, key}
|
{:ok, key}
|
||||||
|
|
||||||
|
wrong = wrong_provider_hint(key, "mailgun") ->
|
||||||
|
{:error, wrong}
|
||||||
|
|
||||||
true ->
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
# Brevo: xkeysib- prefix
|
# Brevo: xkeysib- prefix
|
||||||
defp validate_email_format(key, "brevo", "api_key") do
|
defp validate_email_format(key, "brevo", "api_key") do
|
||||||
if String.starts_with?(key, "xkeysib-") do
|
cond do
|
||||||
{:ok, key}
|
String.starts_with?(key, "xkeysib-") ->
|
||||||
else
|
{:ok, key}
|
||||||
{:error, "Brevo API keys start with xkeysib-. Check you copied the full 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
|
||||||
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
|
defp validate_email_format(key, _adapter_key, _field_key) do
|
||||||
if String.length(key) < 5 do
|
if String.length(key) < 3 do
|
||||||
{:error, "This value looks too short. Check you copied the full key"}
|
{:error, "This value looks too short"}
|
||||||
else
|
else
|
||||||
{:ok, key}
|
{:ok, key}
|
||||||
end
|
end
|
||||||
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 ──
|
# ── Helpers ──
|
||||||
|
|
||||||
defp trim(nil), do: ""
|
defp trim(nil), do: ""
|
||||||
|
|||||||
@ -132,6 +132,101 @@ defmodule Berrypod.Mailer do
|
|||||||
Settings.get_setting("email_from_address") || "noreply@#{default_from_domain()}"
|
Settings.get_setting("email_from_address") || "noreply@#{default_from_domain()}"
|
||||||
end
|
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
|
# Build Swoosh config keyword list from Settings for a given adapter
|
||||||
defp build_config(adapter_info) do
|
defp build_config(adapter_info) do
|
||||||
opts =
|
opts =
|
||||||
|
|||||||
@ -1,5 +1,17 @@
|
|||||||
defmodule Berrypod.Mailer.Adapter do
|
defmodule Berrypod.Mailer.Adapter do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
@enforce_keys [:key, :name, :module, :description, :tags, :fields]
|
@enforce_keys [:key, :name, :module, :description, :fields]
|
||||||
defstruct [:key, :name, :module, :description, :tags, :fields, url: nil]
|
defstruct [
|
||||||
|
:key,
|
||||||
|
:name,
|
||||||
|
:module,
|
||||||
|
:description,
|
||||||
|
:fields,
|
||||||
|
:free_tier,
|
||||||
|
:setup_hint,
|
||||||
|
tags: [],
|
||||||
|
url: nil,
|
||||||
|
category: :all_email,
|
||||||
|
recommended: false
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|||||||
@ -9,20 +9,21 @@ defmodule Berrypod.Mailer.Adapters do
|
|||||||
|
|
||||||
alias Berrypod.Mailer.{Adapter, Field}
|
alias Berrypod.Mailer.{Adapter, Field}
|
||||||
|
|
||||||
# Ordered by capability: transactional + marketing first,
|
|
||||||
# then transactional only, then self-hosted.
|
|
||||||
@adapters [
|
@adapters [
|
||||||
|
# ── Also sends newsletters ──
|
||||||
%Adapter{
|
%Adapter{
|
||||||
key: "smtp",
|
key: "brevo",
|
||||||
name: "SMTP",
|
name: "Brevo",
|
||||||
module: Swoosh.Adapters.SMTP,
|
module: Swoosh.Adapters.Brevo,
|
||||||
description: "Connect to any email server via SMTP. Works with most providers and hosts.",
|
description: "All-in-one platform, GDPR-friendly.",
|
||||||
tags: ["All email", "Any provider"],
|
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: [
|
fields: [
|
||||||
%Field{key: "relay", label: "Server host", type: :string, required: true},
|
%Field{key: "api_key", label: "API key", type: :secret, 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{
|
%Adapter{
|
||||||
@ -31,28 +32,23 @@ defmodule Berrypod.Mailer.Adapters do
|
|||||||
module: Swoosh.Adapters.Sendgrid,
|
module: Swoosh.Adapters.Sendgrid,
|
||||||
description: "Generous free tier, widely used.",
|
description: "Generous free tier, widely used.",
|
||||||
tags: ["All email", "US"],
|
tags: ["All email", "US"],
|
||||||
|
category: :all_email,
|
||||||
|
free_tier: "100 emails/day free",
|
||||||
|
setup_hint: "Paste one API key",
|
||||||
url: "https://sendgrid.com",
|
url: "https://sendgrid.com",
|
||||||
fields: [
|
fields: [
|
||||||
%Field{key: "api_key", label: "API key", type: :secret, required: true}
|
%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{
|
%Adapter{
|
||||||
key: "mailjet",
|
key: "mailjet",
|
||||||
name: "Mailjet",
|
name: "Mailjet",
|
||||||
module: Swoosh.Adapters.Mailjet,
|
module: Swoosh.Adapters.Mailjet,
|
||||||
description: "EU data processing, good free tier.",
|
description: "EU data processing, good free tier.",
|
||||||
tags: ["All email", "France", "GDPR"],
|
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",
|
url: "https://www.mailjet.com",
|
||||||
fields: [
|
fields: [
|
||||||
%Field{key: "api_key", label: "API key", type: :secret, required: true},
|
%Field{key: "api_key", label: "API key", type: :secret, required: true},
|
||||||
@ -65,62 +61,97 @@ defmodule Berrypod.Mailer.Adapters do
|
|||||||
module: Swoosh.Adapters.MailerSend,
|
module: Swoosh.Adapters.MailerSend,
|
||||||
description: "Generous free tier, good analytics dashboard.",
|
description: "Generous free tier, good analytics dashboard.",
|
||||||
tags: ["All email", "EU option"],
|
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",
|
url: "https://www.mailersend.com",
|
||||||
fields: [
|
fields: [
|
||||||
%Field{key: "api_key", label: "API key", type: :secret, required: true}
|
%Field{key: "api_key", label: "API key", type: :secret, required: true}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
# ── Transactional only ──
|
||||||
%Adapter{
|
%Adapter{
|
||||||
key: "resend",
|
key: "resend",
|
||||||
name: "Resend",
|
name: "Resend",
|
||||||
module: Swoosh.Adapters.Resend,
|
module: Swoosh.Adapters.Resend,
|
||||||
description: "Developer-friendly API, simple setup.",
|
description: "Developer-friendly API, simple setup.",
|
||||||
tags: ["Transactional", "US"],
|
tags: ["Transactional", "US"],
|
||||||
|
category: :transactional,
|
||||||
|
free_tier: "3,000 emails/month free",
|
||||||
|
setup_hint: "Paste one API key",
|
||||||
url: "https://resend.com",
|
url: "https://resend.com",
|
||||||
fields: [
|
fields: [
|
||||||
%Field{key: "api_key", label: "API key", type: :secret, required: true}
|
%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{
|
%Adapter{
|
||||||
key: "mailgun",
|
key: "mailgun",
|
||||||
name: "Mailgun",
|
name: "Mailgun",
|
||||||
module: Swoosh.Adapters.Mailgun,
|
module: Swoosh.Adapters.Mailgun,
|
||||||
description: "EU region option available.",
|
description: "EU region option available.",
|
||||||
tags: ["Transactional", "EU option", "Sweden"],
|
tags: ["Transactional", "EU option", "Sweden"],
|
||||||
|
category: :transactional,
|
||||||
|
free_tier: "100 emails/day trial",
|
||||||
|
setup_hint: "API key + domain name",
|
||||||
url: "https://www.mailgun.com",
|
url: "https://www.mailgun.com",
|
||||||
fields: [
|
fields: [
|
||||||
%Field{key: "api_key", label: "API key", type: :secret, required: true},
|
%Field{key: "api_key", label: "API key", type: :secret, required: true},
|
||||||
%Field{key: "domain", label: "Domain", type: :string, 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{
|
%Adapter{
|
||||||
key: "mailpace",
|
key: "mailpace",
|
||||||
name: "MailPace",
|
name: "MailPace",
|
||||||
module: Swoosh.Adapters.MailPace,
|
module: Swoosh.Adapters.MailPace,
|
||||||
description: "Privacy-focused, simple API.",
|
description: "Privacy-focused, simple API.",
|
||||||
tags: ["Transactional", "UK"],
|
tags: ["Transactional", "UK"],
|
||||||
|
category: :transactional,
|
||||||
|
free_tier: "3,000 emails/month free",
|
||||||
|
setup_hint: "Paste one API key",
|
||||||
url: "https://mailpace.com",
|
url: "https://mailpace.com",
|
||||||
fields: [
|
fields: [
|
||||||
%Field{key: "api_key", label: "API key", type: :secret, required: true}
|
%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{
|
%Adapter{
|
||||||
key: "postal",
|
key: "postal",
|
||||||
name: "Postal",
|
name: "Postal",
|
||||||
module: Swoosh.Adapters.Postal,
|
module: Swoosh.Adapters.Postal,
|
||||||
description: "Full control over your email infrastructure.",
|
description: "Full control over your email infrastructure.",
|
||||||
tags: ["All email", "Self-hosted", "Open source"],
|
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",
|
url: "https://docs.postalserver.io",
|
||||||
fields: [
|
fields: [
|
||||||
%Field{key: "api_key", label: "API key", type: :secret, required: true},
|
%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."
|
@doc "Returns all supported adapters."
|
||||||
def all, do: @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."
|
@doc "Returns an adapter by its string key, or nil."
|
||||||
def get(key) when is_binary(key) do
|
def get(key) when is_binary(key) do
|
||||||
Enum.find(@adapters, &(&1.key == key))
|
Enum.find(@adapters, &(&1.key == key))
|
||||||
|
|||||||
@ -13,6 +13,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
saved_adapter = Settings.get_setting("email_adapter")
|
saved_adapter = Settings.get_setting("email_adapter")
|
||||||
|
|
||||||
adapter_key = current_adapter || saved_adapter
|
adapter_key = current_adapter || saved_adapter
|
||||||
|
grouped = Adapters.grouped()
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
@ -21,13 +22,14 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
|> assign(:adapter_key, adapter_key)
|
|> assign(:adapter_key, adapter_key)
|
||||||
|> assign(:current_values, current_values)
|
|> assign(:current_values, current_values)
|
||||||
|> assign(:all_adapters, Adapters.all())
|
|> assign(:all_adapters, Adapters.all())
|
||||||
|> assign(:provider_options, provider_options())
|
|> assign(:recommended_adapters, grouped[:all_email] || [])
|
||||||
|
|> assign(:advanced_adapters, grouped[:advanced] || [])
|
||||||
|> assign(:email_configured, Mailer.email_configured?())
|
|> assign(:email_configured, Mailer.email_configured?())
|
||||||
|> assign(
|
|> assign(:selected_adapter, adapter_key && Adapters.get(adapter_key))
|
||||||
:from_address,
|
|
||||||
Settings.get_setting("email_from_address") || socket.assigns.current_scope.user.email
|
|
||||||
)
|
|
||||||
|> assign(:sending_test, false)
|
|> assign(:sending_test, false)
|
||||||
|
|> assign(:test_result, if(Mailer.email_verified?(), do: :ok))
|
||||||
|
|> assign(:test_error, nil)
|
||||||
|
|> assign(:test_retryable, false)
|
||||||
|> assign(:from_checklist, false)
|
|> assign(:from_checklist, false)
|
||||||
|> assign(:field_errors, %{})
|
|> assign(:field_errors, %{})
|
||||||
|> assign(:form, to_form(%{}, as: :email))}
|
|> assign(:form, to_form(%{}, as: :email))}
|
||||||
@ -58,18 +60,6 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
end
|
end
|
||||||
end
|
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
|
@impl true
|
||||||
def handle_event("change_adapter", %{"email" => %{"adapter" => key}}, socket) do
|
def handle_event("change_adapter", %{"email" => %{"adapter" => key}}, socket) do
|
||||||
values = load_adapter_values(key)
|
values = load_adapter_values(key)
|
||||||
@ -77,8 +67,11 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:adapter_key, key)
|
|> assign(:adapter_key, key)
|
||||||
|
|> assign(:selected_adapter, Adapters.get(key))
|
||||||
|> assign(:current_values, values)
|
|> assign(:current_values, values)
|
||||||
|> assign(:field_errors, %{})}
|
|> assign(:field_errors, %{})
|
||||||
|
|> assign(:test_result, nil)
|
||||||
|
|> assign(:test_error, nil)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("save", %{"email" => params}, socket) do
|
def handle_event("save", %{"email" => params}, socket) do
|
||||||
@ -96,50 +89,28 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
end
|
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
|
|
||||||
|
|
||||||
Mailer.clear_email_verified()
|
|
||||||
|
|
||||||
# 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
|
def handle_event("send_test", _params, socket) do
|
||||||
user = socket.assigns.current_scope.user
|
user = socket.assigns.current_scope.user
|
||||||
|
|
||||||
socket = assign(socket, :sending_test, true)
|
socket = assign(socket, :sending_test, true)
|
||||||
|
|
||||||
case Mailer.send_test_email(user.email, socket.assigns.from_address) do
|
case Mailer.send_test_email(user.email, Mailer.from_address()) do
|
||||||
{:ok, _} ->
|
{:ok, _} ->
|
||||||
Mailer.mark_email_verified()
|
Mailer.mark_email_verified()
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:sending_test, false)
|
|> assign(:sending_test, false)
|
||||||
|> put_flash(:info, "Test email sent to #{user.email}")}
|
|> assign(:test_result, :ok)
|
||||||
|
|> assign(:test_error, nil)}
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:sending_test, false)
|
|> assign(:sending_test, false)
|
||||||
|> put_flash(:error, "Failed to send test email: #{inspect(reason)}")}
|
|> assign(:test_result, :error)
|
||||||
|
|> assign(:test_error, Mailer.friendly_error(reason))
|
||||||
|
|> assign(:test_retryable, Mailer.retryable_error?(reason))}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -183,6 +154,13 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
if field_errors != %{} do
|
if field_errors != %{} do
|
||||||
{:noreply, assign(socket, :field_errors, field_errors)}
|
{:noreply, assign(socket, :field_errors, field_errors)}
|
||||||
else
|
else
|
||||||
|
# Clear settings from other providers
|
||||||
|
new_keys = MapSet.new(Adapters.field_keys(adapter_info))
|
||||||
|
|
||||||
|
for key <- Adapters.all_field_keys(), key not in new_keys do
|
||||||
|
Settings.delete_setting(key)
|
||||||
|
end
|
||||||
|
|
||||||
# Save adapter type
|
# Save adapter type
|
||||||
Settings.put_setting("email_adapter", adapter_info.key)
|
Settings.put_setting("email_adapter", adapter_info.key)
|
||||||
|
|
||||||
@ -207,11 +185,9 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Save from address
|
# Auto-set from address to admin email if not already configured
|
||||||
from_address = params["from_address"] || ""
|
if Settings.get_setting("email_from_address") in [nil, ""] do
|
||||||
|
Settings.put_setting("email_from_address", socket.assigns.current_scope.user.email)
|
||||||
if from_address != "" do
|
|
||||||
Settings.put_setting("email_from_address", from_address)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Config changed — require re-verification
|
# Config changed — require re-verification
|
||||||
@ -226,11 +202,13 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:adapter_key, current_adapter)
|
|> assign(:adapter_key, current_adapter)
|
||||||
|
|> assign(:selected_adapter, Adapters.get(current_adapter))
|
||||||
|> assign(:current_values, current_values)
|
|> assign(:current_values, current_values)
|
||||||
|> assign(:from_address, from_address)
|
|
||||||
|> assign(:email_configured, Mailer.email_configured?())
|
|> assign(:email_configured, Mailer.email_configured?())
|
||||||
|> assign(:field_errors, %{})
|
|> assign(:field_errors, %{})
|
||||||
|> put_flash(:info, "Email settings saved")}
|
|> assign(:test_result, nil)
|
||||||
|
|> assign(:test_error, nil)
|
||||||
|
|> put_flash(:info, "Settings saved — send a test email to check it works")}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -250,9 +228,8 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
<.header>
|
<.header>
|
||||||
Email settings
|
Email settings
|
||||||
<:subtitle>
|
<:subtitle>
|
||||||
Configure how your shop sends email. <strong>Transactional</strong>
|
Your shop needs an email provider to send order confirmations,
|
||||||
providers only handle order confirmations and password resets. <strong>All email</strong>
|
shipping updates, and newsletters to your customers.
|
||||||
providers also support newsletters and marketing campaigns.
|
|
||||||
</:subtitle>
|
</:subtitle>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
@ -277,17 +254,44 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
|
|
||||||
<section class="admin-section">
|
<section class="admin-section">
|
||||||
<.form for={@form} phx-change="change_adapter" phx-submit="save">
|
<.form for={@form} phx-change="change_adapter" phx-submit="save">
|
||||||
<div id="email-provider-cards" phx-hook="CardRadioScroll">
|
<%!-- Step 1: Choose a provider --%>
|
||||||
<.card_radio_group
|
<div class="admin-setup-step">
|
||||||
name="email[adapter]"
|
<div class="admin-setup-step-header">
|
||||||
value={@adapter_key}
|
<span class="admin-setup-step-number">1</span>
|
||||||
legend="Email provider"
|
<h2 class="admin-setup-step-title">Choose a provider</h2>
|
||||||
options={@provider_options}
|
</div>
|
||||||
disabled={@env_locked}
|
<p class="admin-setup-step-desc">
|
||||||
display={:tags}
|
All of these have a free tier. Pick whichever you like.
|
||||||
/>
|
</p>
|
||||||
|
<div id="email-provider-cards" phx-hook="CardRadioScroll">
|
||||||
|
<fieldset class="card-radio-fieldset" disabled={@env_locked}>
|
||||||
|
<div class="card-radio-grid">
|
||||||
|
<.provider_card
|
||||||
|
:for={adapter <- @recommended_adapters}
|
||||||
|
adapter={adapter}
|
||||||
|
selected={@adapter_key}
|
||||||
|
disabled={@env_locked}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="admin-provider-other">
|
||||||
|
<summary class="admin-provider-other-toggle">
|
||||||
|
Already have your own email server?
|
||||||
|
</summary>
|
||||||
|
<div class="card-radio-grid">
|
||||||
|
<.provider_card
|
||||||
|
:for={adapter <- @advanced_adapters}
|
||||||
|
adapter={adapter}
|
||||||
|
selected={@adapter_key}
|
||||||
|
disabled={@env_locked}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%!-- Steps 2 & 3 appear per-adapter after selection --%>
|
||||||
<%= for adapter <- @all_adapters do %>
|
<%= for adapter <- @all_adapters do %>
|
||||||
<% selected = @adapter_key == adapter.key %>
|
<% selected = @adapter_key == adapter.key %>
|
||||||
<div
|
<div
|
||||||
@ -296,82 +300,191 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
hidden={!selected}
|
hidden={!selected}
|
||||||
data-adapter={adapter.key}
|
data-adapter={adapter.key}
|
||||||
>
|
>
|
||||||
<div>
|
<%!-- Step 2: Create an account (providers with sign-up URLs) --%>
|
||||||
<h3 class="admin-section-subheading">
|
<div :if={adapter.url} class="admin-setup-step">
|
||||||
{adapter.name}
|
<div class="admin-setup-step-header">
|
||||||
<.external_link
|
<span class="admin-setup-step-number">2</span>
|
||||||
:if={adapter.url}
|
<h2 class="admin-setup-step-title">Create a free account</h2>
|
||||||
href={adapter.url}
|
|
||||||
icon={false}
|
|
||||||
class="admin-link-subtle admin-adapter-link"
|
|
||||||
aria-label={adapter.name <> " website"}
|
|
||||||
>
|
|
||||||
↗
|
|
||||||
</.external_link>
|
|
||||||
</h3>
|
|
||||||
<p class="admin-section-desc">{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}
|
|
||||||
error={if selected, do: @field_errors[field.key]}
|
|
||||||
/>
|
|
||||||
<% end %>
|
|
||||||
<%= unless @env_locked do %>
|
|
||||||
<div class="admin-row admin-row-lg">
|
|
||||||
<.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="admin-link-danger"
|
|
||||||
>
|
|
||||||
Disconnect
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<p class="admin-setup-step-desc">
|
||||||
|
<.external_link href={adapter.url} class="admin-link">
|
||||||
|
Sign up at {adapter.name} ↗
|
||||||
|
</.external_link>
|
||||||
|
if you don't already have an account. It's free.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Step 3 (or 2 for advanced): Paste your key --%>
|
||||||
|
<div class="admin-setup-step">
|
||||||
|
<div class="admin-setup-step-header">
|
||||||
|
<span class="admin-setup-step-number">
|
||||||
|
{if adapter.url, do: "3", else: "2"}
|
||||||
|
</span>
|
||||||
|
<h2 class="admin-setup-step-title">{adapter_fields_title(adapter)}</h2>
|
||||||
|
</div>
|
||||||
|
<p class="admin-setup-step-desc">{adapter_fields_instruction(adapter)}</p>
|
||||||
|
<%= for field <- adapter.fields do %>
|
||||||
|
<.adapter_field_static
|
||||||
|
field_def={field}
|
||||||
|
value={if selected, do: @current_values[field.key]}
|
||||||
|
disabled={!selected || @env_locked}
|
||||||
|
error={if selected, do: @field_errors[field.key]}
|
||||||
|
/>
|
||||||
|
<% end %>
|
||||||
|
<%= unless @env_locked do %>
|
||||||
|
<div class="admin-row admin-row-lg">
|
||||||
|
<.button phx-disable-with="Saving..." disabled={!selected}>
|
||||||
|
Save settings
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</.form>
|
</.form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<%= if @email_configured do %>
|
<%!-- Step 4: Send a test email (only after config saved) --%>
|
||||||
<section class="admin-section-bordered">
|
<div :if={@email_configured} class="admin-setup-step" style="margin-top: 1.5rem;">
|
||||||
<h2 class="admin-section-heading">Test email</h2>
|
<div class="admin-setup-step-header">
|
||||||
<p class="admin-help-text">
|
<span class={[
|
||||||
Send a test email to <strong>{@current_scope.user.email}</strong>
|
"admin-setup-step-number",
|
||||||
to verify delivery works.
|
@test_result == :ok && "admin-setup-step-number-done",
|
||||||
|
@test_result == :error && "admin-setup-step-number-error"
|
||||||
|
]}>
|
||||||
|
<%= cond do %>
|
||||||
|
<% @test_result == :ok -> %>
|
||||||
|
<.icon name="hero-check-mini" class="size-4" />
|
||||||
|
<% @test_result == :error -> %>
|
||||||
|
<.icon name="hero-x-mark-mini" class="size-4" />
|
||||||
|
<% true -> %>
|
||||||
|
{if @selected_adapter && @selected_adapter.url, do: "4", else: "3"}
|
||||||
|
<% end %>
|
||||||
|
</span>
|
||||||
|
<h2 class="admin-setup-step-title">
|
||||||
|
<%= cond do %>
|
||||||
|
<% @test_result == :ok -> %>
|
||||||
|
Email is working
|
||||||
|
<% @test_result == :error -> %>
|
||||||
|
Test failed
|
||||||
|
<% true -> %>
|
||||||
|
Send a test email
|
||||||
|
<% end %>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if @test_result == :ok do %>
|
||||||
|
<p class="admin-setup-step-desc">
|
||||||
|
Test email sent to <strong>{@current_scope.user.email}</strong>.
|
||||||
|
Check your inbox to confirm it arrived.
|
||||||
</p>
|
</p>
|
||||||
<div class="admin-section-body">
|
<div class="admin-row admin-row-sm">
|
||||||
<button
|
<.link
|
||||||
phx-click="send_test"
|
:if={@from_checklist}
|
||||||
disabled={@sending_test}
|
navigate={~p"/admin"}
|
||||||
class="admin-btn admin-btn-outline"
|
class="admin-btn admin-btn-primary admin-btn-sm"
|
||||||
>
|
>
|
||||||
<.icon name="hero-paper-airplane" class="size-4" />
|
Continue setup →
|
||||||
{if @sending_test, do: "Sending...", else: "Send test email"}
|
</.link>
|
||||||
</button>
|
<.button type="button" phx-click="send_test">
|
||||||
|
Send again
|
||||||
|
</.button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<% else %>
|
||||||
<% end %>
|
<%= if @test_result == :error do %>
|
||||||
|
<p class="admin-test-error">{@test_error}</p>
|
||||||
|
<div>
|
||||||
|
<%= if @test_retryable do %>
|
||||||
|
<button
|
||||||
|
phx-click="send_test"
|
||||||
|
disabled={@sending_test}
|
||||||
|
class="admin-btn admin-btn-outline admin-btn-error"
|
||||||
|
>
|
||||||
|
<.icon name="hero-paper-airplane" class="size-4" />
|
||||||
|
{if @sending_test, do: "Sending...", else: "Try again"}
|
||||||
|
</button>
|
||||||
|
<% else %>
|
||||||
|
<p class="admin-setup-step-desc">
|
||||||
|
Fix your settings above and reconnect, then try the test again.
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="admin-setup-step-desc">
|
||||||
|
Send a test to <strong>{@current_scope.user.email}</strong> to check everything works.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
phx-click="send_test"
|
||||||
|
disabled={@sending_test}
|
||||||
|
class="admin-btn admin-btn-outline"
|
||||||
|
>
|
||||||
|
<.icon name="hero-paper-airplane" class="size-4" />
|
||||||
|
{if @sending_test, do: "Sending...", else: "Send test email"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ── Local components ──
|
||||||
|
|
||||||
|
defp adapter_fields_title(%{key: "smtp"}), do: "Enter your server details"
|
||||||
|
defp adapter_fields_title(%{key: "postal"}), do: "Enter your server details"
|
||||||
|
defp adapter_fields_title(%{key: "mailjet"}), do: "Paste your API keys"
|
||||||
|
defp adapter_fields_title(_adapter), do: "Paste your API key"
|
||||||
|
|
||||||
|
defp adapter_fields_instruction(%{key: "smtp"}),
|
||||||
|
do: "Enter your SMTP server connection details below."
|
||||||
|
|
||||||
|
defp adapter_fields_instruction(%{key: "postal"}),
|
||||||
|
do: "Enter your Postal server URL and API key below."
|
||||||
|
|
||||||
|
defp adapter_fields_instruction(%{key: "mailjet"}),
|
||||||
|
do: "Find your API key and secret key under API Key Management in your Mailjet account."
|
||||||
|
|
||||||
|
defp adapter_fields_instruction(%{key: "mailgun"}),
|
||||||
|
do: "Find your API key in your Mailgun dashboard, and enter your sending domain."
|
||||||
|
|
||||||
|
defp adapter_fields_instruction(adapter),
|
||||||
|
do: "Find your API key in your #{adapter.name} account settings and paste it here."
|
||||||
|
|
||||||
|
attr :adapter, :map, required: true
|
||||||
|
attr :selected, :string, default: nil
|
||||||
|
attr :disabled, :boolean, default: false
|
||||||
|
|
||||||
|
defp provider_card(assigns) do
|
||||||
|
~H"""
|
||||||
|
<label class={[
|
||||||
|
"card-radio-card",
|
||||||
|
@selected == @adapter.key && "card-radio-card-selected"
|
||||||
|
]}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id={"email-adapter-#{@adapter.key}"}
|
||||||
|
name="email[adapter]"
|
||||||
|
value={@adapter.key}
|
||||||
|
checked={@selected == @adapter.key}
|
||||||
|
disabled={@disabled}
|
||||||
|
class="card-radio-input"
|
||||||
|
/>
|
||||||
|
<span class="card-radio-name">
|
||||||
|
{@adapter.name}
|
||||||
|
<span :if={@adapter.recommended} class="card-radio-badge card-radio-recommended">
|
||||||
|
Recommended
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span :if={@adapter.free_tier} class="card-radio-description">{@adapter.free_tier}</span>
|
||||||
|
<span :if={@adapter.setup_hint} class="card-radio-description">{@adapter.setup_hint}</span>
|
||||||
|
</label>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Field renderers ──
|
||||||
|
|
||||||
attr :field_def, :map, required: true
|
attr :field_def, :map, required: true
|
||||||
attr :value, :any, default: nil
|
attr :value, :any, default: nil
|
||||||
attr :disabled, :boolean, default: false
|
attr :disabled, :boolean, default: false
|
||||||
@ -381,24 +494,17 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
assigns = assign(assigns, :errors, if(assigns.error, do: [assigns.error], else: []))
|
assigns = assign(assigns, :errors, if(assigns.error, do: [assigns.error], else: []))
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div>
|
<.input
|
||||||
<.input
|
name={"email[#{@field_def.key}]"}
|
||||||
name={"email[#{@field_def.key}]"}
|
value=""
|
||||||
value=""
|
type="text"
|
||||||
type="text"
|
label={@field_def.label}
|
||||||
label={@field_def.label}
|
autocomplete="off"
|
||||||
autocomplete="off"
|
placeholder={@value || ""}
|
||||||
placeholder={if @value, do: @value, else: ""}
|
required={@field_def.required && !@value}
|
||||||
required={@field_def.required && !@value}
|
disabled={@disabled}
|
||||||
disabled={@disabled}
|
errors={@errors}
|
||||||
errors={@errors}
|
/>
|
||||||
/>
|
|
||||||
<%= if @value && !@disabled do %>
|
|
||||||
<p class="admin-help-text">
|
|
||||||
Current: <code>{@value}</code> — leave blank to keep existing value
|
|
||||||
</p>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@ defmodule BerrypodWeb.Admin.Settings do
|
|||||||
|> assign(:page_title, "Settings")
|
|> assign(:page_title, "Settings")
|
||||||
|> assign(:site_live, Settings.site_live?())
|
|> assign(:site_live, Settings.site_live?())
|
||||||
|> assign(:cart_recovery_enabled, Settings.abandoned_cart_recovery_enabled?())
|
|> assign(:cart_recovery_enabled, Settings.abandoned_cart_recovery_enabled?())
|
||||||
|
|> assign(:from_address, Settings.get_setting("email_from_address") || user.email)
|
||||||
|> assign_stripe_state()
|
|> assign_stripe_state()
|
||||||
|> assign_products_state()
|
|> assign_products_state()
|
||||||
|> assign_account_state(user)}
|
|> assign_account_state(user)}
|
||||||
@ -108,6 +109,23 @@ defmodule BerrypodWeb.Admin.Settings do
|
|||||||
|> put_flash(:info, message)}
|
|> put_flash(:info, message)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# -- Events: from address --
|
||||||
|
|
||||||
|
def handle_event("save_from_address", %{"from_address" => address}, socket) do
|
||||||
|
address = String.trim(address)
|
||||||
|
|
||||||
|
if address != "" do
|
||||||
|
Settings.put_setting("email_from_address", address)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:from_address, address)
|
||||||
|
|> put_flash(:info, "From address saved")}
|
||||||
|
else
|
||||||
|
{:noreply, put_flash(socket, :error, "From address can't be blank")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# -- Events: Stripe --
|
# -- Events: Stripe --
|
||||||
|
|
||||||
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
|
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
|
||||||
@ -415,6 +433,25 @@ defmodule BerrypodWeb.Admin.Settings do
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<%!-- From address --%>
|
||||||
|
<section class="admin-section">
|
||||||
|
<h2 class="admin-section-title">From address</h2>
|
||||||
|
<p class="admin-section-desc">
|
||||||
|
The sender address on all emails from your shop.
|
||||||
|
</p>
|
||||||
|
<div class="admin-section-body">
|
||||||
|
<form phx-submit="save_from_address" class="admin-row admin-row-lg">
|
||||||
|
<.input
|
||||||
|
name="from_address"
|
||||||
|
value={@from_address}
|
||||||
|
type="email"
|
||||||
|
placeholder="noreply@yourshop.com"
|
||||||
|
/>
|
||||||
|
<.button phx-disable-with="Saving...">Save</.button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<%!-- Account --%>
|
<%!-- Account --%>
|
||||||
<section class="admin-section">
|
<section class="admin-section">
|
||||||
<h2 class="admin-section-title">Account</h2>
|
<h2 class="admin-section-title">Account</h2>
|
||||||
|
|||||||
@ -170,19 +170,99 @@ defmodule Berrypod.KeyValidationTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "validate_email_key/3 - unknown providers" do
|
describe "validate_email_key/3 - Mailjet" do
|
||||||
test "accepts reasonable key for unknown adapter" do
|
test "accepts valid api_key" do
|
||||||
assert {:ok, "some_api_key_12345"} =
|
key = String.duplicate("a1b2c3d4", 4)
|
||||||
KeyValidation.validate_email_key("some_api_key_12345", "mailersend", "api_key")
|
assert {:ok, ^key} = KeyValidation.validate_email_key(key, "mailjet", "api_key")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "accepts reasonable key for non-api_key fields" do
|
test "accepts valid secret" do
|
||||||
|
key = String.duplicate("e5f6a7b8", 4)
|
||||||
|
assert {:ok, ^key} = KeyValidation.validate_email_key(key, "mailjet", "secret")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects short api_key" do
|
||||||
|
assert {:error, msg} = KeyValidation.validate_email_key("short", "mailjet", "api_key")
|
||||||
|
assert msg =~ "too short"
|
||||||
|
assert msg =~ "Mailjet"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects short secret" do
|
||||||
|
assert {:error, msg} = KeyValidation.validate_email_key("short", "mailjet", "secret")
|
||||||
|
assert msg =~ "too short"
|
||||||
|
assert msg =~ "Mailjet"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "validate_email_key/3 - MailerSend" do
|
||||||
|
test "accepts key with mlsn. prefix" do
|
||||||
|
key = "mlsn." <> String.duplicate("ab", 20)
|
||||||
|
assert {:ok, ^key} = KeyValidation.validate_email_key(key, "mailersend", "api_key")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "accepts long key without known prefix" do
|
||||||
|
key = String.duplicate("x", 40)
|
||||||
|
assert {:ok, ^key} = KeyValidation.validate_email_key(key, "mailersend", "api_key")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects short key" do
|
||||||
|
assert {:error, msg} = KeyValidation.validate_email_key("short", "mailersend", "api_key")
|
||||||
|
assert msg =~ "mlsn."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "validate_email_key/3 - MailPace" do
|
||||||
|
test "accepts valid token" do
|
||||||
|
key = "abc123def456ghi789"
|
||||||
|
assert {:ok, ^key} = KeyValidation.validate_email_key(key, "mailpace", "api_key")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects short token" do
|
||||||
|
assert {:error, msg} = KeyValidation.validate_email_key("short", "mailpace", "api_key")
|
||||||
|
assert msg =~ "too short"
|
||||||
|
assert msg =~ "MailPace"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "validate_email_key/3 - cross-provider detection" do
|
||||||
|
test "detects Brevo key pasted into SendGrid" do
|
||||||
|
key = "xkeysib-abc123def456"
|
||||||
|
assert {:error, msg} = KeyValidation.validate_email_key(key, "sendgrid", "api_key")
|
||||||
|
assert msg =~ "Brevo key"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "detects SendGrid key pasted into Brevo" do
|
||||||
|
key = "SG.abc123.def456"
|
||||||
|
assert {:error, msg} = KeyValidation.validate_email_key(key, "brevo", "api_key")
|
||||||
|
assert msg =~ "SendGrid key"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "detects Resend key pasted into Postmark" do
|
||||||
|
key = "re_abc123xyz456"
|
||||||
|
assert {:error, msg} = KeyValidation.validate_email_key(key, "postmark", "api_key")
|
||||||
|
assert msg =~ "Resend key"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "detects MailerSend key pasted into Mailgun" do
|
||||||
|
key = "mlsn.abc123"
|
||||||
|
assert {:error, msg} = KeyValidation.validate_email_key(key, "mailgun", "api_key")
|
||||||
|
assert msg =~ "MailerSend key"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "validate_email_key/3 - non-api_key fields" do
|
||||||
|
test "accepts reasonable value for domain" do
|
||||||
|
assert {:ok, "mg.example.com"} =
|
||||||
|
KeyValidation.validate_email_key("mg.example.com", "mailgun", "domain")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "accepts reasonable value for relay" do
|
||||||
assert {:ok, "smtp.example.com"} =
|
assert {:ok, "smtp.example.com"} =
|
||||||
KeyValidation.validate_email_key("smtp.example.com", "smtp", "relay")
|
KeyValidation.validate_email_key("smtp.example.com", "smtp", "relay")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "rejects very short value" do
|
test "rejects very short value" do
|
||||||
assert {:error, msg} = KeyValidation.validate_email_key("ab", "mailersend", "api_key")
|
assert {:error, msg} = KeyValidation.validate_email_key("ab", "smtp", "relay")
|
||||||
assert msg =~ "too short"
|
assert msg =~ "too short"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -88,6 +88,125 @@ defmodule Berrypod.MailerTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "friendly_error/1" do
|
||||||
|
# API errors with status codes
|
||||||
|
test "401 with message body" do
|
||||||
|
error = {401, %{"code" => "unauthorized", "message" => "Key not found"}}
|
||||||
|
result = Mailer.friendly_error(error)
|
||||||
|
assert result =~ "API key was rejected"
|
||||||
|
assert result =~ "key not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "401 without message" do
|
||||||
|
assert Mailer.friendly_error({401, ""}) =~ "API key was rejected"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "403 with message" do
|
||||||
|
error = {403, %{"message" => "Insufficient permissions"}}
|
||||||
|
result = Mailer.friendly_error(error)
|
||||||
|
assert result =~ "permission"
|
||||||
|
assert result =~ "insufficient permissions"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "422 with errors array" do
|
||||||
|
error = {422, %{"errors" => [%{"message" => "Invalid sender address"}]}}
|
||||||
|
result = Mailer.friendly_error(error)
|
||||||
|
assert result =~ "rejected"
|
||||||
|
assert result =~ "invalid sender address"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "429 rate limit" do
|
||||||
|
assert Mailer.friendly_error({429, %{}}) =~ "Rate limit"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "500 server error" do
|
||||||
|
assert Mailer.friendly_error({500, %{"message" => "Internal error"}}) =~ "having issues"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "Postmark ErrorMessage format" do
|
||||||
|
error = {422, %{"ErrorCode" => 300, "ErrorMessage" => "Invalid 'From' address"}}
|
||||||
|
result = Mailer.friendly_error(error)
|
||||||
|
assert result =~ "invalid 'From' address"
|
||||||
|
end
|
||||||
|
|
||||||
|
# SMTP errors
|
||||||
|
test "permanent SMTP failure" do
|
||||||
|
assert Mailer.friendly_error({:permanent_failure, "550 User not found"}) =~ "rejected"
|
||||||
|
|
||||||
|
assert Mailer.friendly_error({:permanent_failure, "550 User not found"}) =~
|
||||||
|
"550 User not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "temporary SMTP failure" do
|
||||||
|
assert Mailer.friendly_error({:temporary_failure, "421 Try again"}) =~
|
||||||
|
"temporarily unavailable"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "SMTP retries exceeded" do
|
||||||
|
assert Mailer.friendly_error({:retries_exceeded, nil}) =~ "connection failed"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "SMTP auth failed" do
|
||||||
|
assert Mailer.friendly_error({:auth_failed, nil}) =~ "login failed"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Network errors
|
||||||
|
test "timeout" do
|
||||||
|
assert Mailer.friendly_error(:timeout) =~ "timed out"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "connection refused" do
|
||||||
|
assert Mailer.friendly_error(:econnrefused) =~ "Connection refused"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "nxdomain" do
|
||||||
|
assert Mailer.friendly_error(:nxdomain) =~ "hostname not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Catch-all
|
||||||
|
test "unknown error falls back to inspect" do
|
||||||
|
assert Mailer.friendly_error({:something, :weird}) =~ "Delivery failed"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "string errors pass through" do
|
||||||
|
assert Mailer.friendly_error("some error") == "some error"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "retryable_error?/1" do
|
||||||
|
test "401 is not retryable" do
|
||||||
|
refute Mailer.retryable_error?({401, %{}})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "403 is not retryable" do
|
||||||
|
refute Mailer.retryable_error?({403, %{}})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "429 is retryable" do
|
||||||
|
assert Mailer.retryable_error?({429, %{}})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "500 is retryable" do
|
||||||
|
assert Mailer.retryable_error?({500, %{}})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "timeout is retryable" do
|
||||||
|
assert Mailer.retryable_error?(:timeout)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "temporary SMTP failure is retryable" do
|
||||||
|
assert Mailer.retryable_error?({:temporary_failure, "421 Try again"})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "permanent SMTP failure is not retryable" do
|
||||||
|
refute Mailer.retryable_error?({:permanent_failure, "550 Bad mailbox"})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "auth failure is not retryable" do
|
||||||
|
refute Mailer.retryable_error?({:auth_failed, nil})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "current_config/0" do
|
describe "current_config/0" do
|
||||||
test "returns {nil, %{}} when no adapter configured" do
|
test "returns {nil, %{}} when no adapter configured" do
|
||||||
Application.put_env(:berrypod, Mailer, adapter: Swoosh.Adapters.Local)
|
Application.put_env(:berrypod, Mailer, adapter: Swoosh.Adapters.Local)
|
||||||
|
|||||||
@ -29,21 +29,20 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|
|||||||
{:ok, _view, html} = live(conn, ~p"/admin/settings/email")
|
{:ok, _view, html} = live(conn, ~p"/admin/settings/email")
|
||||||
|
|
||||||
assert html =~ "Email settings"
|
assert html =~ "Email settings"
|
||||||
assert html =~ "Email provider"
|
assert html =~ "Choose a provider"
|
||||||
# Provider names rendered as radio cards
|
# Provider names rendered as radio cards
|
||||||
assert html =~ "Postmark"
|
|
||||||
assert html =~ "Brevo"
|
assert html =~ "Brevo"
|
||||||
assert html =~ "Mailjet"
|
assert html =~ "Mailjet"
|
||||||
assert html =~ "MailPace"
|
assert html =~ "MailPace"
|
||||||
assert html =~ "Postal"
|
assert html =~ "Postal"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows provider descriptions", %{conn: conn} do
|
test "shows setup guidance", %{conn: conn} do
|
||||||
{:ok, _view, html} = live(conn, ~p"/admin/settings/email")
|
{:ok, _view, html} = live(conn, ~p"/admin/settings/email")
|
||||||
|
|
||||||
assert html =~ "Excellent deliverability tracking"
|
assert html =~ "needs an email provider"
|
||||||
assert html =~ "All-in-one platform, GDPR-friendly"
|
assert html =~ "Paste your API key"
|
||||||
assert html =~ "EU data processing"
|
assert html =~ "300 emails/day free"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "selecting a provider shows its config fields", %{conn: conn} do
|
test "selecting a provider shows its config fields", %{conn: conn} do
|
||||||
@ -64,72 +63,60 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|
|||||||
test "selecting a different provider shows different fields", %{conn: conn} do
|
test "selecting a different provider shows different fields", %{conn: conn} do
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
||||||
|
|
||||||
# Select Mailgun which needs api_key + domain
|
# Select Brevo which needs just an api_key
|
||||||
html =
|
html =
|
||||||
view
|
view
|
||||||
|> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "mailgun"}})
|
|> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "brevo"}})
|
||||||
|> render_change()
|
|> render_change()
|
||||||
|
|
||||||
assert html =~ "API key"
|
assert html =~ "API key"
|
||||||
assert html =~ "Domain"
|
assert html =~ "Brevo"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "saving config persists settings", %{conn: conn} do
|
test "saving config persists settings", %{conn: conn} do
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
||||||
|
|
||||||
# Select Postmark via form change
|
# Select Brevo via form change
|
||||||
view
|
view
|
||||||
|> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "postmark"}})
|
|> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "brevo"}})
|
||||||
|> render_change()
|
|> render_change()
|
||||||
|
|
||||||
# Submit with an API key (Postmark uses UUID format)
|
# Submit with an API key
|
||||||
html =
|
html =
|
||||||
view
|
view
|
||||||
|> form("form[phx-submit=\"save\"]", %{
|
|> form("form[phx-submit=\"save\"]", %{
|
||||||
email: %{adapter: "postmark", api_key: "abc12345-abcd-1234-abcd-123456789abc"}
|
email: %{adapter: "brevo", api_key: "xkeysib-abc123def456"}
|
||||||
})
|
})
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|
|
||||||
assert html =~ "Email settings saved"
|
assert html =~ "Settings saved"
|
||||||
assert Settings.get_setting("email_adapter") == "postmark"
|
assert Settings.get_setting("email_adapter") == "brevo"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "saving without required fields shows error", %{conn: conn} do
|
test "saving without required fields shows error", %{conn: conn} do
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
||||||
|
|
||||||
# Select Postmark
|
# Select Brevo
|
||||||
view
|
view
|
||||||
|> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "postmark"}})
|
|> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "brevo"}})
|
||||||
|> render_change()
|
|> render_change()
|
||||||
|
|
||||||
# Submit without API key
|
# Submit without API key
|
||||||
html =
|
html =
|
||||||
view
|
view
|
||||||
|> form("form[phx-submit=\"save\"]", %{email: %{adapter: "postmark", api_key: ""}})
|
|> form("form[phx-submit=\"save\"]", %{email: %{adapter: "brevo", api_key: ""}})
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|
|
||||||
assert html =~ "API key is required"
|
assert html =~ "API key is required"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "disconnect clears email configuration", %{conn: conn} do
|
|
||||||
Settings.put_setting("email_adapter", "postmark")
|
|
||||||
Settings.put_secret("email_postmark_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
|
test "shows test email section when configured", %{conn: conn} do
|
||||||
Settings.put_setting("email_adapter", "postmark")
|
Settings.put_setting("email_adapter", "postmark")
|
||||||
Settings.put_secret("email_postmark_api_key", "pm_test_abc")
|
Settings.put_secret("email_postmark_api_key", "pm_test_abc")
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, ~p"/admin/settings/email")
|
{:ok, _view, html} = live(conn, ~p"/admin/settings/email")
|
||||||
|
|
||||||
assert html =~ "Test email"
|
assert html =~ "Send a test email"
|
||||||
assert html =~ "Send test email"
|
assert html =~ "Send test email"
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -143,7 +130,7 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|
|||||||
refute html =~ "Send test email"
|
refute html =~ "Send test email"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "sending test email sets verified flag", %{conn: conn} do
|
test "sending test email shows success and sets verified flag", %{conn: conn} do
|
||||||
Settings.put_setting("email_adapter", "postmark")
|
Settings.put_setting("email_adapter", "postmark")
|
||||||
Settings.put_secret("email_postmark_api_key", "pm_test_abc")
|
Settings.put_secret("email_postmark_api_key", "pm_test_abc")
|
||||||
|
|
||||||
@ -151,10 +138,38 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|
|||||||
|
|
||||||
html = render_click(view, "send_test")
|
html = render_click(view, "send_test")
|
||||||
|
|
||||||
assert html =~ "Test email sent"
|
assert html =~ "Email is working"
|
||||||
|
assert html =~ "Send again"
|
||||||
assert Mailer.email_verified?()
|
assert Mailer.email_verified?()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "switching provider clears old provider settings", %{conn: conn} do
|
||||||
|
# Save Mailjet config first
|
||||||
|
Settings.put_setting("email_adapter", "mailjet")
|
||||||
|
Settings.put_secret("email_mailjet_api_key", "mj-key-123")
|
||||||
|
Settings.put_secret("email_mailjet_secret", "mj-secret-456")
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
||||||
|
|
||||||
|
# Switch to Brevo and save
|
||||||
|
view
|
||||||
|
|> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "brevo"}})
|
||||||
|
|> render_change()
|
||||||
|
|
||||||
|
view
|
||||||
|
|> form("form[phx-submit=\"save\"]", %{
|
||||||
|
email: %{adapter: "brevo", api_key: "xkeysib-switch-test"}
|
||||||
|
})
|
||||||
|
|> render_submit()
|
||||||
|
|
||||||
|
# Old Mailjet settings should be deleted
|
||||||
|
assert Settings.get_setting("email_adapter") == "brevo"
|
||||||
|
refute Settings.has_secret?("email_mailjet_api_key")
|
||||||
|
refute Settings.has_secret?("email_mailjet_secret")
|
||||||
|
# New Brevo key should exist
|
||||||
|
assert Settings.has_secret?("email_brevo_api_key")
|
||||||
|
end
|
||||||
|
|
||||||
test "saving config clears verified flag", %{conn: conn} do
|
test "saving config clears verified flag", %{conn: conn} do
|
||||||
Mailer.mark_email_verified()
|
Mailer.mark_email_verified()
|
||||||
assert Mailer.email_verified?()
|
assert Mailer.email_verified?()
|
||||||
@ -162,30 +177,17 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|
|||||||
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
||||||
|
|
||||||
view
|
view
|
||||||
|> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "postmark"}})
|
|> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "brevo"}})
|
||||||
|> render_change()
|
|> render_change()
|
||||||
|
|
||||||
view
|
view
|
||||||
|> form("form[phx-submit=\"save\"]", %{
|
|> form("form[phx-submit=\"save\"]", %{
|
||||||
email: %{adapter: "postmark", api_key: "def12345-abcd-1234-abcd-123456789def"}
|
email: %{adapter: "brevo", api_key: "xkeysib-def789ghi012"}
|
||||||
})
|
})
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|
|
||||||
refute Mailer.email_verified?()
|
refute Mailer.email_verified?()
|
||||||
end
|
end
|
||||||
|
|
||||||
test "disconnecting clears verified flag", %{conn: conn} do
|
|
||||||
Settings.put_setting("email_adapter", "postmark")
|
|
||||||
Settings.put_secret("email_postmark_api_key", "pm_test_abc")
|
|
||||||
Mailer.mark_email_verified()
|
|
||||||
assert Mailer.email_verified?()
|
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
|
||||||
|
|
||||||
render_click(view, "disconnect")
|
|
||||||
|
|
||||||
refute Mailer.email_verified?()
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "unauthenticated" do
|
describe "unauthenticated" do
|
||||||
|
|||||||
@ -240,6 +240,32 @@ defmodule BerrypodWeb.Admin.SettingsTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "from address" do
|
||||||
|
setup %{conn: conn, user: user} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
%{conn: conn}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows from address section", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/admin/settings")
|
||||||
|
|
||||||
|
assert html =~ "From address"
|
||||||
|
assert html =~ "sender address"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "saves from address", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/settings")
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> form("form[phx-submit=\"save_from_address\"]", %{from_address: "shop@example.com"})
|
||||||
|
|> render_submit()
|
||||||
|
|
||||||
|
assert html =~ "From address saved"
|
||||||
|
assert Settings.get_setting("email_from_address") == "shop@example.com"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "advanced section" do
|
describe "advanced section" do
|
||||||
setup %{conn: conn, user: user} do
|
setup %{conn: conn, user: user} do
|
||||||
conn = log_in_user(conn, user)
|
conn = log_in_user(conn, user)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user