rework email settings for true progressive enhancement
All checks were successful
deploy / deploy (push) Successful in 1m19s
All checks were successful
deploy / deploy (push) Successful in 1m19s
Render all adapter field sections in the form with CSS :has(:checked) controlling visibility. Selecting a provider instantly shows its config fields — no JS, no page reload, no server round-trip needed. - Render all 6 adapter configs with data-adapter attribute - CSS :has(:checked) show/hide rules per adapter in admin stylesheet - Namespace field names per adapter (email[brevo][api_key] etc) - Drop 4 transactional-only providers (Resend, Postmark, Mailgun, MailPace) - Remove noscript "Switch provider" button and controller redirect workaround - Remove configured_adapter hidden input tracking - Hide JS-only test email button for no-JS users via noscript style - LiveView progressively enhances with async save and test email Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
dd20ea824f
commit
db130a7155
@ -24,7 +24,7 @@ Tier 1 MVP complete. Tier 2 production readiness complete (except Litestream and
|
|||||||
- Abandoned cart recovery (GDPR-compliant, single email)
|
- Abandoned cart recovery (GDPR-compliant, single email)
|
||||||
- Favicon generation from source image (PNG variants, SVG dark mode, webmanifest)
|
- Favicon generation from source image (PNG variants, SVG dark mode, webmanifest)
|
||||||
- Complete SEO (OG/Twitter cards, JSON-LD, sitemap, canonical URLs, meta descriptions)
|
- Complete SEO (OG/Twitter cards, JSON-LD, sitemap, canonical URLs, meta descriptions)
|
||||||
- Email settings admin with 10 adapter options and test email
|
- Email settings admin with 6 adapter options, no-JS support, and test email
|
||||||
- No-JS support across all key flows
|
- No-JS support across all key flows
|
||||||
- Fully Tailwind-free CSS (12 KB gzipped shop+theme, 95 KB gzipped admin total)
|
- Fully Tailwind-free CSS (12 KB gzipped shop+theme, 95 KB gzipped admin total)
|
||||||
- CI pipeline (compile warnings, format, credo, dialyzer, tests)
|
- CI pipeline (compile warnings, format, credo, dialyzer, tests)
|
||||||
@ -60,7 +60,7 @@ Based on usability testing (March 2026). Reworks the entire setup flow into a si
|
|||||||
| A | Simplify initial setup to account creation only (email, password, shop name) | 1.5h | planned |
|
| A | Simplify initial setup to account creation only (email, password, shop name) | 1.5h | planned |
|
||||||
| B | Guided setup flow with progress bar (multi-step, skippable, explains "why") | 4h | planned |
|
| B | Guided setup flow with progress bar (multi-step, skippable, explains "why") | 4h | planned |
|
||||||
| C | Forgiving API key validation (strip whitespace, format checks, helpful errors) | 1.5h | done |
|
| C | Forgiving API key validation (strip whitespace, format checks, helpful errors) | 1.5h | done |
|
||||||
| D | Email provider setup UX rework (recommended pick, grouping, guided flow, test email) | 2h | planned |
|
| D | Email provider setup UX rework (6 adapters, no-JS, `:has(:checked)` CSS, test email) | 2h | done |
|
||||||
| E | Contextual prompts for skipped steps (products, checkout, order detail) | 2h | done |
|
| E | Contextual prompts for skipped steps (products, checkout, order detail) | 2h | done |
|
||||||
| F | Dashboard checklist and messaging rework | 2h | planned |
|
| F | Dashboard checklist and messaging rework | 2h | planned |
|
||||||
| G | Coming soon page fixes (logo layout, admin login link) | 30m | done |
|
| G | Coming soon page fixes (logo layout, admin login link) | 30m | done |
|
||||||
|
|||||||
@ -1492,7 +1492,7 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.card-radio-card-selected {
|
&:has(:checked) {
|
||||||
border-color: var(--t-text-primary, #171717);
|
border-color: var(--t-text-primary, #171717);
|
||||||
background: var(--t-surface-sunken, #e5e5e5);
|
background: var(--t-surface-sunken, #e5e5e5);
|
||||||
}
|
}
|
||||||
@ -4379,6 +4379,20 @@
|
|||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Show only the adapter config matching the checked radio */
|
||||||
|
.admin-adapter-config[data-adapter] { display: none; }
|
||||||
|
|
||||||
|
.admin-content-medium:has(#email-adapter-brevo:checked) [data-adapter="brevo"],
|
||||||
|
.admin-content-medium:has(#email-adapter-sendgrid:checked) [data-adapter="sendgrid"],
|
||||||
|
.admin-content-medium:has(#email-adapter-mailjet:checked) [data-adapter="mailjet"],
|
||||||
|
.admin-content-medium:has(#email-adapter-mailersend:checked) [data-adapter="mailersend"],
|
||||||
|
.admin-content-medium:has(#email-adapter-smtp:checked) [data-adapter="smtp"],
|
||||||
|
.admin-content-medium:has(#email-adapter-postal:checked) [data-adapter="postal"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Campaign form ── */
|
/* ── Campaign form ── */
|
||||||
|
|
||||||
.admin-campaign-actions {
|
.admin-campaign-actions {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Onboarding UX v2
|
# Onboarding UX v2
|
||||||
|
|
||||||
Status: Planned
|
Status: In progress
|
||||||
|
|
||||||
Supersedes the original onboarding-ux plan. Based on usability testing session (March 2026) covering the setup wizard, launch checklist, email provider setup, and general onboarding flow.
|
Supersedes the original onboarding-ux plan. Based on usability testing session (March 2026) covering the setup wizard, launch checklist, email provider setup, and general onboarding flow.
|
||||||
|
|
||||||
@ -77,20 +77,31 @@ API key entry should be as forgiving as possible:
|
|||||||
|
|
||||||
**Files:** `lib/berrypod_web/live/setup/onboarding.ex`, `lib/berrypod_web/live/admin/providers.ex`, validation logic in contexts
|
**Files:** `lib/berrypod_web/live/setup/onboarding.ex`, `lib/berrypod_web/live/admin/providers.ex`, validation logic in contexts
|
||||||
|
|
||||||
### D. Email provider setup UX
|
### D. Email provider setup UX — done
|
||||||
|
|
||||||
Rework the email provider selection and configuration:
|
Reworked the email provider selection and configuration. Changes across two sessions:
|
||||||
|
|
||||||
- **Recommended pick** highlighted at top — whichever has the best free tier, easiest setup, and supports newsletters. One clear "if you're not sure, pick this" option.
|
**Session 1 (commit dd659e4):**
|
||||||
- **Two groups:** "Also sends newsletters" vs "Transactional only" with one-line explanation of the difference
|
- Recommended pick (Brevo) highlighted with badge
|
||||||
- **One-liner per provider:** free tier info, setup difficulty ("paste one API key" vs "requires domain verification")
|
- Two groups: "Also sends newsletters" vs "Transactional only"
|
||||||
- **Self-hosted/SMTP** in a collapsible "Advanced" section at bottom
|
- One-liner per provider with free tier info
|
||||||
- **Guided flow after selection:** link to create account (new tab), then "Now enter these settings:" with config fields
|
- Self-hosted/SMTP in collapsible "Advanced" section
|
||||||
- **Send test email** button after configuration
|
- Guided flow after selection with config fields
|
||||||
- **Remove** the masked key display ("Current: 84159e26•••4f3")
|
- Send test email button with async delivery
|
||||||
- **Default from address** to the admin email automatically — don't surface during setup, it's a general settings thing for later
|
- Removed masked key display
|
||||||
|
- Default from address auto-set from admin email
|
||||||
|
- No-JS support via controller POST fallback
|
||||||
|
- 17 audit fixes (a11y, error handling, UX)
|
||||||
|
|
||||||
**Files:** `lib/berrypod_web/live/admin/email_settings.ex`, shared components for guided flow
|
**Session 2 (uncommitted):**
|
||||||
|
- Dropped 4 transactional-only providers (Resend, Postmark, Mailgun, MailPace) — all are transactional-only and not useful for a shop that needs newsletters. 6 adapters remain: Brevo, SendGrid, Mailjet, MailerSend + SMTP, Postal
|
||||||
|
- Removed "Popular providers" heading and description (unnecessary with fewer providers)
|
||||||
|
- Fixed no-JS provider switching: noscript submit button always visible with dynamic text
|
||||||
|
- Fixed no-JS radio highlight: replaced server-applied `card-radio-card-selected` class with pure CSS `:has(:checked)` selector
|
||||||
|
- Cleaned up key validation (removed 4 provider clauses, removed 2 known prefixes)
|
||||||
|
- Updated all related tests (adapters, key validation, email settings LiveView, controller, mailer)
|
||||||
|
|
||||||
|
**Files:** `lib/berrypod_web/live/admin/email_settings.ex`, `lib/berrypod/mailer/adapters.ex`, `lib/berrypod/key_validation.ex`, `assets/css/admin/components.css`, `lib/berrypod_web/controllers/email_settings_controller.ex`
|
||||||
|
|
||||||
### E. Contextual prompts for skipped steps
|
### E. Contextual prompts for skipped steps
|
||||||
|
|
||||||
@ -148,7 +159,7 @@ Increase input field border contrast to meet WCAG AA (3:1 minimum for UI compone
|
|||||||
| A | Simplify initial setup to account creation only | 1.5h | planned |
|
| A | Simplify initial setup to account creation only | 1.5h | planned |
|
||||||
| B | Guided setup flow with progress bar | 4h | planned |
|
| B | Guided setup flow with progress bar | 4h | planned |
|
||||||
| C | Forgiving API key validation | 1.5h | planned |
|
| C | Forgiving API key validation | 1.5h | planned |
|
||||||
| D | Email provider setup UX rework | 2h | planned |
|
| D | Email provider setup UX rework | 2h | done |
|
||||||
| E | Contextual prompts for skipped steps | 2h | done |
|
| E | Contextual prompts for skipped steps | 2h | done |
|
||||||
| F | Dashboard checklist and messaging rework | 2h | planned |
|
| F | Dashboard checklist and messaging rework | 2h | planned |
|
||||||
| G | Coming soon page fixes (logo + admin link) | 30m | planned |
|
| G | Coming soon page fixes (logo + admin link) | 30m | planned |
|
||||||
|
|||||||
@ -84,8 +84,6 @@ defmodule Berrypod.KeyValidation do
|
|||||||
@known_prefixes [
|
@known_prefixes [
|
||||||
{"SG.", "SendGrid"},
|
{"SG.", "SendGrid"},
|
||||||
{"xkeysib-", "Brevo"},
|
{"xkeysib-", "Brevo"},
|
||||||
{"re_", "Resend"},
|
|
||||||
{"key-", "Mailgun"},
|
|
||||||
{"mlsn.", "MailerSend"}
|
{"mlsn.", "MailerSend"}
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -98,56 +96,6 @@ defmodule Berrypod.KeyValidation do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Postmark: UUID format
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
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) or long RBAC keys
|
|
||||||
defp validate_email_format(key, "mailgun", "api_key") do
|
|
||||||
cond do
|
|
||||||
String.starts_with?(key, "key-") ->
|
|
||||||
{:ok, key}
|
|
||||||
|
|
||||||
String.length(key) >= 20 ->
|
|
||||||
{:ok, key}
|
|
||||||
|
|
||||||
wrong = wrong_provider_hint(key, "mailgun") ->
|
|
||||||
{:error, wrong}
|
|
||||||
|
|
||||||
true ->
|
|
||||||
{:error,
|
|
||||||
"Mailgun API keys usually start with key- — find yours in Settings → API Security"}
|
|
||||||
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
|
||||||
cond do
|
cond do
|
||||||
@ -210,21 +158,6 @@ defmodule Berrypod.KeyValidation do
|
|||||||
end
|
end
|
||||||
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
|
# 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) < 3 do
|
if String.length(key) < 3 do
|
||||||
|
|||||||
@ -69,64 +69,6 @@ defmodule Berrypod.Mailer.Adapters do
|
|||||||
%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{
|
|
||||||
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: "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 ──
|
# ── Advanced ──
|
||||||
%Adapter{
|
%Adapter{
|
||||||
key: "smtp",
|
key: "smtp",
|
||||||
|
|||||||
@ -10,27 +10,22 @@ defmodule BerrypodWeb.EmailSettingsController do
|
|||||||
alias Berrypod.Mailer
|
alias Berrypod.Mailer
|
||||||
|
|
||||||
def update(conn, %{"email" => params}) do
|
def update(conn, %{"email" => params}) do
|
||||||
selected = params["adapter"]
|
adapter_key = params["adapter"]
|
||||||
configured = params["configured_adapter"]
|
# Fields are namespaced: email[brevo][api_key] → params["brevo"]["api_key"]
|
||||||
|
adapter_params = params[adapter_key] || %{}
|
||||||
|
|
||||||
if selected != configured do
|
case Mailer.save_config(adapter_key, adapter_params, conn.assigns.current_scope.user.email) do
|
||||||
# User changed adapter radio but config fields are for the old adapter.
|
{:ok, _adapter_info} ->
|
||||||
# Redirect to show the new adapter's config fields.
|
conn
|
||||||
redirect(conn, to: ~p"/admin/settings/email?adapter=#{selected}")
|
|> put_flash(:info, "Settings saved — send a test email to check it works")
|
||||||
else
|
|> redirect(to: ~p"/admin/settings/email")
|
||||||
case Mailer.save_config(selected, params, conn.assigns.current_scope.user.email) do
|
|
||||||
{:ok, _adapter_info} ->
|
|
||||||
conn
|
|
||||||
|> put_flash(:info, "Settings saved — send a test email to check it works")
|
|
||||||
|> redirect(to: ~p"/admin/settings/email")
|
|
||||||
|
|
||||||
{:error, field_errors} when is_map(field_errors) ->
|
{:error, field_errors} when is_map(field_errors) ->
|
||||||
message = field_errors |> Map.values() |> Enum.join(". ")
|
message = field_errors |> Map.values() |> Enum.join(". ")
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> put_flash(:error, message)
|
|> put_flash(:error, message)
|
||||||
|> redirect(to: ~p"/admin/settings/email?adapter=#{selected}")
|
|> redirect(to: ~p"/admin/settings/email")
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -8,21 +8,23 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
env_locked = Mailer.env_var_configured?()
|
env_locked = Mailer.env_var_configured?()
|
||||||
{current_adapter, current_values} = Mailer.current_config()
|
{current_adapter, _current_values} = Mailer.current_config()
|
||||||
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()
|
grouped = Adapters.grouped()
|
||||||
|
all_adapters = Adapters.all()
|
||||||
|
all_values = load_all_adapter_values()
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, "Email settings")
|
|> assign(:page_title, "Email settings")
|
||||||
|> assign(:env_locked, env_locked)
|
|> assign(:env_locked, env_locked)
|
||||||
|> assign(:adapter_key, adapter_key)
|
|> assign(:adapter_key, adapter_key)
|
||||||
|> assign(:current_values, current_values)
|
|> assign(:all_values, all_values)
|
||||||
|> assign(:all_email_adapters, grouped[:all_email] || [])
|
|> assign(:all_email_adapters, grouped[:all_email] || [])
|
||||||
|> assign(:transactional_adapters, grouped[:transactional] || [])
|
|
||||||
|> assign(:advanced_adapters, grouped[:advanced] || [])
|
|> assign(:advanced_adapters, grouped[:advanced] || [])
|
||||||
|
|> assign(:all_adapters, all_adapters)
|
||||||
|> assign(:email_configured, Mailer.email_configured?())
|
|> assign(:email_configured, Mailer.email_configured?())
|
||||||
|> assign(:selected_adapter, adapter_key && Adapters.get(adapter_key))
|
|> assign(:selected_adapter, adapter_key && Adapters.get(adapter_key))
|
||||||
|> assign(:sending_test, false)
|
|> assign(:sending_test, false)
|
||||||
@ -36,37 +38,14 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_params(params, _uri, socket) do
|
def handle_params(params, _uri, socket) do
|
||||||
# Support ?adapter=X for no-JS adapter switching
|
|
||||||
adapter_key = params["adapter"] || socket.assigns.adapter_key
|
|
||||||
|
|
||||||
socket =
|
|
||||||
if adapter_key && adapter_key != socket.assigns.adapter_key do
|
|
||||||
values = load_adapter_values(adapter_key)
|
|
||||||
|
|
||||||
socket
|
|
||||||
|> assign(:adapter_key, adapter_key)
|
|
||||||
|> assign(:selected_adapter, Adapters.get(adapter_key))
|
|
||||||
|> assign(:current_values, values)
|
|
||||||
|> assign(:field_errors, %{})
|
|
||||||
|> assign(:test_result, nil)
|
|
||||||
|> assign(:test_error, nil)
|
|
||||||
else
|
|
||||||
socket
|
|
||||||
end
|
|
||||||
|
|
||||||
{:noreply, assign(socket, :from_checklist, params["from"] == "checklist")}
|
{:noreply, assign(socket, :from_checklist, params["from"] == "checklist")}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp load_adapter_values(nil), do: %{}
|
defp load_all_adapter_values do
|
||||||
|
for adapter <- Adapters.all(), into: %{} do
|
||||||
defp load_adapter_values(adapter_key) do
|
values =
|
||||||
case Adapters.get(adapter_key) do
|
for field <- adapter.fields, into: %{} do
|
||||||
nil ->
|
settings_key = Adapters.settings_key(adapter.key, field.key)
|
||||||
%{}
|
|
||||||
|
|
||||||
adapter_info ->
|
|
||||||
for field <- adapter_info.fields, into: %{} do
|
|
||||||
settings_key = Adapters.settings_key(adapter_key, field.key)
|
|
||||||
|
|
||||||
value =
|
value =
|
||||||
case field.type do
|
case field.type do
|
||||||
@ -76,6 +55,8 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
|
|
||||||
{field.key, value}
|
{field.key, value}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
{adapter.key, values}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -84,13 +65,10 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
if key == socket.assigns.adapter_key do
|
if key == socket.assigns.adapter_key do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
else
|
else
|
||||||
values = load_adapter_values(key)
|
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:adapter_key, key)
|
|> assign(:adapter_key, key)
|
||||||
|> assign(:selected_adapter, Adapters.get(key))
|
|> assign(:selected_adapter, Adapters.get(key))
|
||||||
|> assign(:current_values, values)
|
|
||||||
|> assign(:field_errors, %{})
|
|> assign(:field_errors, %{})
|
||||||
|> assign(:test_result, nil)
|
|> assign(:test_result, nil)
|
||||||
|> assign(:test_error, nil)}
|
|> assign(:test_error, nil)}
|
||||||
@ -102,16 +80,20 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
{:noreply, put_flash(socket, :error, "Email config is controlled by environment variables")}
|
{:noreply, put_flash(socket, :error, "Email config is controlled by environment variables")}
|
||||||
else
|
else
|
||||||
adapter_key = params["adapter"]
|
adapter_key = params["adapter"]
|
||||||
|
# Fields are namespaced: email[brevo][api_key] → params["brevo"]["api_key"]
|
||||||
|
adapter_params = params[adapter_key] || %{}
|
||||||
|
|
||||||
case Mailer.save_config(adapter_key, params, socket.assigns.current_scope.user.email) do
|
case Mailer.save_config(
|
||||||
|
adapter_key,
|
||||||
|
adapter_params,
|
||||||
|
socket.assigns.current_scope.user.email
|
||||||
|
) do
|
||||||
{:ok, _adapter_info} ->
|
{:ok, _adapter_info} ->
|
||||||
{current_adapter, current_values} = Mailer.current_config()
|
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:adapter_key, current_adapter)
|
|> assign(:adapter_key, adapter_key)
|
||||||
|> assign(:selected_adapter, Adapters.get(current_adapter))
|
|> assign(:selected_adapter, Adapters.get(adapter_key))
|
||||||
|> assign(:current_values, current_values)
|
|> assign(:all_values, load_all_adapter_values())
|
||||||
|> assign(:email_configured, Mailer.email_configured?())
|
|> assign(:email_configured, Mailer.email_configured?())
|
||||||
|> assign(:field_errors, %{})
|
|> assign(:field_errors, %{})
|
||||||
|> assign(:test_result, nil)
|
|> assign(:test_result, nil)
|
||||||
@ -204,13 +186,6 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
phx-change="form_change"
|
phx-change="form_change"
|
||||||
phx-submit="save"
|
phx-submit="save"
|
||||||
>
|
>
|
||||||
<%!-- Hidden field tracks which adapter's config fields are rendered --%>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="email[configured_adapter]"
|
|
||||||
value={@adapter_key || ""}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<%!-- Step 1: Choose a provider --%>
|
<%!-- Step 1: Choose a provider --%>
|
||||||
<div class="admin-setup-step">
|
<div class="admin-setup-step">
|
||||||
<div class="admin-setup-step-header">
|
<div class="admin-setup-step-header">
|
||||||
@ -221,16 +196,6 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
<fieldset class="card-radio-fieldset" disabled={@env_locked}>
|
<fieldset class="card-radio-fieldset" disabled={@env_locked}>
|
||||||
<legend class="sr-only">Email provider</legend>
|
<legend class="sr-only">Email provider</legend>
|
||||||
|
|
||||||
<h3 class="card-radio-group-heading">Popular providers</h3>
|
|
||||||
<p class="card-radio-group-desc">
|
|
||||||
Newsletters and transactional emails. All have free tiers.
|
|
||||||
<span
|
|
||||||
:if={Enum.any?(@all_email_adapters, & &1.recommended)}
|
|
||||||
class="card-radio-group-hint"
|
|
||||||
>
|
|
||||||
Not sure which? Pick the recommended one.
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<div class="card-radio-grid">
|
<div class="card-radio-grid">
|
||||||
<.provider_card
|
<.provider_card
|
||||||
:for={adapter <- @all_email_adapters}
|
:for={adapter <- @all_email_adapters}
|
||||||
@ -240,20 +205,6 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="card-radio-group-heading">Transactional only</h3>
|
|
||||||
<p class="card-radio-group-desc">
|
|
||||||
Order confirmations and shipping updates.
|
|
||||||
You'll need a separate service for newsletters later.
|
|
||||||
</p>
|
|
||||||
<div class="card-radio-grid">
|
|
||||||
<.provider_card
|
|
||||||
:for={adapter <- @transactional_adapters}
|
|
||||||
adapter={adapter}
|
|
||||||
selected={@adapter_key}
|
|
||||||
disabled={@env_locked}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<details class="admin-provider-other">
|
<details class="admin-provider-other">
|
||||||
<summary class="admin-provider-other-toggle">
|
<summary class="admin-provider-other-toggle">
|
||||||
Already have your own email server?
|
Already have your own email server?
|
||||||
@ -270,60 +221,24 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Steps 2 & 3 appear for the selected adapter only --%>
|
<%!-- Steps 2 & 3 for each adapter (CSS :has(:checked) shows the active one) --%>
|
||||||
<div :if={@selected_adapter} class="admin-adapter-config">
|
<.adapter_config
|
||||||
<%!-- Step 2: Create an account (providers with sign-up URLs) --%>
|
:for={adapter <- @all_adapters}
|
||||||
<div :if={@selected_adapter.url} class="admin-setup-step">
|
adapter={adapter}
|
||||||
<div class="admin-setup-step-header">
|
values={@all_values[adapter.key] || %{}}
|
||||||
<span class="admin-setup-step-number">2</span>
|
field_errors={if(@adapter_key == adapter.key, do: @field_errors, else: %{})}
|
||||||
<h2 class="admin-setup-step-title">Create a free account</h2>
|
env_locked={@env_locked}
|
||||||
</div>
|
/>
|
||||||
<p class="admin-setup-step-desc">
|
|
||||||
<.external_link href={@selected_adapter.url} class="admin-link">
|
|
||||||
Sign up at {@selected_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 @selected_adapter.url, do: "3", else: "2"}
|
|
||||||
</span>
|
|
||||||
<h2 class="admin-setup-step-title">{adapter_fields_title(@selected_adapter)}</h2>
|
|
||||||
</div>
|
|
||||||
<p class="admin-setup-step-desc">{adapter_fields_instruction(@selected_adapter)}</p>
|
|
||||||
<%= for field <- @selected_adapter.fields do %>
|
|
||||||
<.adapter_field_static
|
|
||||||
field_def={field}
|
|
||||||
value={@current_values[field.key]}
|
|
||||||
disabled={@env_locked}
|
|
||||||
error={@field_errors[field.key]}
|
|
||||||
/>
|
|
||||||
<% end %>
|
|
||||||
<%= unless @env_locked do %>
|
|
||||||
<div class="admin-row admin-row-lg">
|
|
||||||
<.button phx-disable-with="Saving...">
|
|
||||||
Save settings
|
|
||||||
</.button>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- No-JS: if adapter changed but config not yet shown, submit to reload --%>
|
|
||||||
<noscript>
|
|
||||||
<div :if={!@selected_adapter} class="admin-row admin-row-lg" style="margin-top: 1rem;">
|
|
||||||
<.button>Continue</.button>
|
|
||||||
</div>
|
|
||||||
</noscript>
|
|
||||||
</.form>
|
</.form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<%!-- Step 4: Send a test email (only after config saved) --%>
|
<%!-- Step 4: Send a test email (only after config saved) --%>
|
||||||
<div :if={@email_configured} class="admin-setup-step" style="margin-top: 1.5rem;">
|
<div
|
||||||
|
:if={@email_configured}
|
||||||
|
id="test-email-step"
|
||||||
|
class="admin-setup-step"
|
||||||
|
style="margin-top: 1.5rem;"
|
||||||
|
>
|
||||||
<div class="admin-setup-step-header">
|
<div class="admin-setup-step-header">
|
||||||
<span class={[
|
<span class={[
|
||||||
"admin-setup-step-number",
|
"admin-setup-step-number",
|
||||||
@ -393,23 +308,31 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
Send a test to <strong>{@current_scope.user.email}</strong> to check everything works.
|
Send a test to <strong>{@current_scope.user.email}</strong> to check everything works.
|
||||||
</p>
|
</p>
|
||||||
<div class="admin-row admin-row-sm">
|
<div class="admin-row admin-row-sm">
|
||||||
<.button
|
<%!-- JS: async send via LiveView --%>
|
||||||
type="button"
|
<span id="test-email-js">
|
||||||
phx-click="send_test"
|
<.button
|
||||||
disabled={@sending_test}
|
type="button"
|
||||||
phx-disable-with="Sending..."
|
phx-click="send_test"
|
||||||
>
|
disabled={@sending_test}
|
||||||
<.icon name="hero-paper-airplane" class="size-4" /> Send test email
|
phx-disable-with="Sending..."
|
||||||
</.button>
|
>
|
||||||
<%!-- No-JS fallback for test email --%>
|
<.icon name="hero-paper-airplane" class="size-4" /> Send test email
|
||||||
|
</.button>
|
||||||
|
</span>
|
||||||
|
<%!-- No-JS: form POST fallback (hides the JS button above) --%>
|
||||||
<noscript>
|
<noscript>
|
||||||
|
<style>
|
||||||
|
#test-email-js { display: none; }
|
||||||
|
</style>
|
||||||
<.form
|
<.form
|
||||||
for={%{}}
|
for={%{}}
|
||||||
action={~p"/admin/settings/email/test"}
|
action={~p"/admin/settings/email/test"}
|
||||||
method="post"
|
method="post"
|
||||||
style="display:inline"
|
style="display:inline"
|
||||||
>
|
>
|
||||||
<.button>Send test email</.button>
|
<.button>
|
||||||
|
<.icon name="hero-paper-airplane" class="size-4" /> Send test email
|
||||||
|
</.button>
|
||||||
</.form>
|
</.form>
|
||||||
</noscript>
|
</noscript>
|
||||||
</div>
|
</div>
|
||||||
@ -422,6 +345,58 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
|
|
||||||
# ── Local components ──
|
# ── Local components ──
|
||||||
|
|
||||||
|
attr :adapter, :map, required: true
|
||||||
|
attr :values, :map, required: true
|
||||||
|
attr :field_errors, :map, required: true
|
||||||
|
attr :env_locked, :boolean, required: true
|
||||||
|
|
||||||
|
defp adapter_config(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="admin-adapter-config" data-adapter={@adapter.key}>
|
||||||
|
<%!-- Create an account (providers with sign-up URLs) --%>
|
||||||
|
<div :if={@adapter.url} class="admin-setup-step">
|
||||||
|
<div class="admin-setup-step-header">
|
||||||
|
<span class="admin-setup-step-number">2</span>
|
||||||
|
<h2 class="admin-setup-step-title">Create a free account</h2>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<%!-- Paste your key / server details --%>
|
||||||
|
<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_input
|
||||||
|
adapter_key={@adapter.key}
|
||||||
|
field_def={field}
|
||||||
|
value={@values[field.key]}
|
||||||
|
disabled={@env_locked}
|
||||||
|
error={@field_errors[field.key]}
|
||||||
|
/>
|
||||||
|
<% end %>
|
||||||
|
<%= unless @env_locked do %>
|
||||||
|
<div class="admin-row admin-row-lg">
|
||||||
|
<.button phx-disable-with="Saving...">
|
||||||
|
Save settings
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
defp adapter_fields_title(%{key: "smtp"}), do: "Enter your server details"
|
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: "postal"}), do: "Enter your server details"
|
||||||
defp adapter_fields_title(%{key: "mailjet"}), do: "Paste your API keys"
|
defp adapter_fields_title(%{key: "mailjet"}), do: "Paste your API keys"
|
||||||
@ -448,10 +423,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
|
|
||||||
defp provider_card(assigns) do
|
defp provider_card(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<label class={[
|
<label class="card-radio-card">
|
||||||
"card-radio-card",
|
|
||||||
@selected == @adapter.key && "card-radio-card-selected"
|
|
||||||
]}>
|
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
id={"email-adapter-#{@adapter.key}"}
|
id={"email-adapter-#{@adapter.key}"}
|
||||||
@ -474,18 +446,20 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
end
|
end
|
||||||
|
|
||||||
# ── Field renderers ──
|
# ── Field renderers ──
|
||||||
|
# Fields are namespaced per adapter: email[brevo][api_key], email[smtp][relay], etc.
|
||||||
|
|
||||||
|
attr :adapter_key, :string, required: true
|
||||||
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
|
||||||
attr :error, :string, default: nil
|
attr :error, :string, default: nil
|
||||||
|
|
||||||
defp adapter_field_static(%{field_def: %{type: :secret}} = assigns) do
|
defp adapter_field_input(%{field_def: %{type: :secret}} = assigns) 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"""
|
||||||
<.input
|
<.input
|
||||||
name={"email[#{@field_def.key}]"}
|
name={"email[#{@adapter_key}][#{@field_def.key}]"}
|
||||||
value=""
|
value=""
|
||||||
type="text"
|
type="text"
|
||||||
label={@field_def.label}
|
label={@field_def.label}
|
||||||
@ -497,12 +471,12 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
defp adapter_field_static(%{field_def: %{type: :integer}} = assigns) do
|
defp adapter_field_input(%{field_def: %{type: :integer}} = assigns) 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"""
|
||||||
<.input
|
<.input
|
||||||
name={"email[#{@field_def.key}]"}
|
name={"email[#{@adapter_key}][#{@field_def.key}]"}
|
||||||
value={@value || @field_def.default || ""}
|
value={@value || @field_def.default || ""}
|
||||||
type="number"
|
type="number"
|
||||||
label={@field_def.label}
|
label={@field_def.label}
|
||||||
@ -512,12 +486,12 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
defp adapter_field_static(assigns) do
|
defp adapter_field_input(assigns) 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"""
|
||||||
<.input
|
<.input
|
||||||
name={"email[#{@field_def.key}]"}
|
name={"email[#{@adapter_key}][#{@field_def.key}]"}
|
||||||
value={@value || @field_def.default || ""}
|
value={@value || @field_def.default || ""}
|
||||||
type="text"
|
type="text"
|
||||||
label={@field_def.label}
|
label={@field_def.label}
|
||||||
|
|||||||
@ -112,52 +112,6 @@ defmodule Berrypod.KeyValidationTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "validate_email_key/3 - Postmark" do
|
|
||||||
test "accepts valid UUID" do
|
|
||||||
key = "abc12345-abcd-1234-abcd-123456789abc"
|
|
||||||
assert {:ok, ^key} = KeyValidation.validate_email_key(key, "postmark", "api_key")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "accepts uppercase UUID" do
|
|
||||||
key = "ABC12345-ABCD-1234-ABCD-123456789ABC"
|
|
||||||
assert {:ok, ^key} = KeyValidation.validate_email_key(key, "postmark", "api_key")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "rejects non-UUID" do
|
|
||||||
assert {:error, msg} = KeyValidation.validate_email_key("not-a-uuid", "postmark", "api_key")
|
|
||||||
assert msg =~ "UUID"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "validate_email_key/3 - Resend" do
|
|
||||||
test "accepts valid Resend key" do
|
|
||||||
assert {:ok, "re_abc123xyz"} =
|
|
||||||
KeyValidation.validate_email_key("re_abc123xyz", "resend", "api_key")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "rejects key without re_ prefix" do
|
|
||||||
assert {:error, msg} = KeyValidation.validate_email_key("not_resend", "resend", "api_key")
|
|
||||||
assert msg =~ "re_"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "validate_email_key/3 - Mailgun" do
|
|
||||||
test "accepts classic key- format" do
|
|
||||||
key = "key-abcdef1234567890abcdef1234567890"
|
|
||||||
assert {:ok, ^key} = KeyValidation.validate_email_key(key, "mailgun", "api_key")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "accepts newer long key without prefix" do
|
|
||||||
key = "abcdef1234567890abcdef1234567890abcdef1234"
|
|
||||||
assert {:ok, ^key} = KeyValidation.validate_email_key(key, "mailgun", "api_key")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "rejects short key without prefix" do
|
|
||||||
assert {:error, msg} = KeyValidation.validate_email_key("short", "mailgun", "api_key")
|
|
||||||
assert msg =~ "key-"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "validate_email_key/3 - Brevo" do
|
describe "validate_email_key/3 - Brevo" do
|
||||||
test "accepts valid Brevo key" do
|
test "accepts valid Brevo key" do
|
||||||
key = "xkeysib-" <> String.duplicate("ab", 32)
|
key = "xkeysib-" <> String.duplicate("ab", 32)
|
||||||
@ -211,19 +165,6 @@ defmodule Berrypod.KeyValidationTest do
|
|||||||
end
|
end
|
||||||
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
|
describe "validate_email_key/3 - cross-provider detection" do
|
||||||
test "detects Brevo key pasted into SendGrid" do
|
test "detects Brevo key pasted into SendGrid" do
|
||||||
key = "xkeysib-abc123def456"
|
key = "xkeysib-abc123def456"
|
||||||
@ -237,25 +178,14 @@ defmodule Berrypod.KeyValidationTest do
|
|||||||
assert msg =~ "SendGrid key"
|
assert msg =~ "SendGrid key"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "detects Resend key pasted into Postmark" do
|
test "detects MailerSend key pasted into Brevo" 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"
|
key = "mlsn.abc123"
|
||||||
assert {:error, msg} = KeyValidation.validate_email_key(key, "mailgun", "api_key")
|
assert {:error, msg} = KeyValidation.validate_email_key(key, "brevo", "api_key")
|
||||||
assert msg =~ "MailerSend key"
|
assert msg =~ "MailerSend key"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "validate_email_key/3 - non-api_key fields" do
|
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
|
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")
|
||||||
|
|||||||
@ -7,7 +7,7 @@ defmodule Berrypod.Mailer.AdaptersTest do
|
|||||||
test "returns a list of adapters" do
|
test "returns a list of adapters" do
|
||||||
adapters = Adapters.all()
|
adapters = Adapters.all()
|
||||||
assert is_list(adapters)
|
assert is_list(adapters)
|
||||||
assert length(adapters) >= 9
|
assert length(adapters) >= 5
|
||||||
end
|
end
|
||||||
|
|
||||||
test "each adapter has required keys" do
|
test "each adapter has required keys" do
|
||||||
@ -43,10 +43,10 @@ defmodule Berrypod.Mailer.AdaptersTest do
|
|||||||
describe "get/1" do
|
describe "get/1" do
|
||||||
test "returns adapter by key" do
|
test "returns adapter by key" do
|
||||||
assert %{key: "smtp", name: "SMTP"} = Adapters.get("smtp")
|
assert %{key: "smtp", name: "SMTP"} = Adapters.get("smtp")
|
||||||
assert %{key: "postmark", name: "Postmark"} = Adapters.get("postmark")
|
|
||||||
assert %{key: "mailjet", name: "Mailjet"} = Adapters.get("mailjet")
|
assert %{key: "mailjet", name: "Mailjet"} = Adapters.get("mailjet")
|
||||||
assert %{key: "mailpace", name: "MailPace"} = Adapters.get("mailpace")
|
|
||||||
assert %{key: "postal", name: "Postal"} = Adapters.get("postal")
|
assert %{key: "postal", name: "Postal"} = Adapters.get("postal")
|
||||||
|
assert is_nil(Adapters.get("postmark"))
|
||||||
|
assert is_nil(Adapters.get("mailpace"))
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns nil for unknown key" do
|
test "returns nil for unknown key" do
|
||||||
@ -70,7 +70,6 @@ defmodule Berrypod.Mailer.AdaptersTest do
|
|||||||
test "returns unique keys from all adapters" do
|
test "returns unique keys from all adapters" do
|
||||||
keys = Adapters.all_field_keys()
|
keys = Adapters.all_field_keys()
|
||||||
assert is_list(keys)
|
assert is_list(keys)
|
||||||
assert "email_postmark_api_key" in keys
|
|
||||||
assert "email_smtp_relay" in keys
|
assert "email_smtp_relay" in keys
|
||||||
assert length(keys) == length(Enum.uniq(keys))
|
assert length(keys) == length(Enum.uniq(keys))
|
||||||
end
|
end
|
||||||
@ -78,7 +77,7 @@ defmodule Berrypod.Mailer.AdaptersTest do
|
|||||||
|
|
||||||
describe "settings_key/2" do
|
describe "settings_key/2" do
|
||||||
test "namespaces key with adapter" do
|
test "namespaces key with adapter" do
|
||||||
assert Adapters.settings_key("postmark", "api_key") == "email_postmark_api_key"
|
assert Adapters.settings_key("brevo", "api_key") == "email_brevo_api_key"
|
||||||
assert Adapters.settings_key("smtp", "relay") == "email_smtp_relay"
|
assert Adapters.settings_key("smtp", "relay") == "email_smtp_relay"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -25,14 +25,14 @@ defmodule Berrypod.MailerTest do
|
|||||||
|
|
||||||
describe "load_config/0" do
|
describe "load_config/0" do
|
||||||
test "loads adapter config from settings" do
|
test "loads adapter config from settings" do
|
||||||
Settings.put_setting("email_adapter", "postmark")
|
Settings.put_setting("email_adapter", "brevo")
|
||||||
Settings.put_secret("email_postmark_api_key", "pm_test_key_123")
|
Settings.put_secret("email_brevo_api_key", "xkeysib-test-key-123")
|
||||||
|
|
||||||
Mailer.load_config()
|
Mailer.load_config()
|
||||||
|
|
||||||
config = Application.get_env(:berrypod, Mailer)
|
config = Application.get_env(:berrypod, Mailer)
|
||||||
assert config[:adapter] == Swoosh.Adapters.Postmark
|
assert config[:adapter] == Swoosh.Adapters.Brevo
|
||||||
assert config[:api_key] == "pm_test_key_123"
|
assert config[:api_key] == "xkeysib-test-key-123"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "loads SMTP config with multiple fields" do
|
test "loads SMTP config with multiple fields" do
|
||||||
@ -283,13 +283,13 @@ defmodule Berrypod.MailerTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "returns adapter key and config when configured from settings" do
|
test "returns adapter key and config when configured from settings" do
|
||||||
Settings.put_setting("email_adapter", "postmark")
|
Settings.put_setting("email_adapter", "brevo")
|
||||||
Settings.put_secret("email_postmark_api_key", "pm_test_key_123")
|
Settings.put_secret("email_brevo_api_key", "xkeysib-test-key-123")
|
||||||
|
|
||||||
Mailer.load_config()
|
Mailer.load_config()
|
||||||
|
|
||||||
{adapter_key, config} = Mailer.current_config()
|
{adapter_key, config} = Mailer.current_config()
|
||||||
assert adapter_key == "postmark"
|
assert adapter_key == "brevo"
|
||||||
assert config["api_key"] =~ "•••"
|
assert config["api_key"] =~ "•••"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -24,8 +24,7 @@ defmodule BerrypodWeb.EmailSettingsControllerTest do
|
|||||||
post(conn, ~p"/admin/settings/email", %{
|
post(conn, ~p"/admin/settings/email", %{
|
||||||
email: %{
|
email: %{
|
||||||
adapter: "brevo",
|
adapter: "brevo",
|
||||||
configured_adapter: "brevo",
|
brevo: %{api_key: "xkeysib-abc123def456"}
|
||||||
api_key: "xkeysib-abc123def456"
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -34,19 +33,10 @@ defmodule BerrypodWeb.EmailSettingsControllerTest do
|
|||||||
assert Settings.get_setting("email_adapter") == "brevo"
|
assert Settings.get_setting("email_adapter") == "brevo"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "redirects with adapter param when adapter changed", %{conn: conn} do
|
|
||||||
conn =
|
|
||||||
post(conn, ~p"/admin/settings/email", %{
|
|
||||||
email: %{adapter: "resend", configured_adapter: "brevo", api_key: ""}
|
|
||||||
})
|
|
||||||
|
|
||||||
assert redirected_to(conn) == ~p"/admin/settings/email?adapter=resend"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "redirects with error on validation failure", %{conn: conn} do
|
test "redirects with error on validation failure", %{conn: conn} do
|
||||||
conn =
|
conn =
|
||||||
post(conn, ~p"/admin/settings/email", %{
|
post(conn, ~p"/admin/settings/email", %{
|
||||||
email: %{adapter: "brevo", configured_adapter: "brevo", api_key: ""}
|
email: %{adapter: "brevo", brevo: %{api_key: ""}}
|
||||||
})
|
})
|
||||||
|
|
||||||
assert redirected_to(conn) =~ ~p"/admin/settings/email"
|
assert redirected_to(conn) =~ ~p"/admin/settings/email"
|
||||||
@ -56,8 +46,8 @@ defmodule BerrypodWeb.EmailSettingsControllerTest do
|
|||||||
|
|
||||||
describe "POST /admin/settings/email/test" do
|
describe "POST /admin/settings/email/test" do
|
||||||
test "sends test email and redirects", %{conn: conn} do
|
test "sends test email and redirects", %{conn: conn} do
|
||||||
Settings.put_setting("email_adapter", "postmark")
|
Settings.put_setting("email_adapter", "brevo")
|
||||||
Settings.put_secret("email_postmark_api_key", "pm_test_abc")
|
Settings.put_secret("email_brevo_api_key", "xkeysib-test-abc")
|
||||||
|
|
||||||
conn = post(conn, ~p"/admin/settings/email/test")
|
conn = post(conn, ~p"/admin/settings/email/test")
|
||||||
|
|
||||||
|
|||||||
@ -33,27 +33,17 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|
|||||||
# Provider names rendered as radio cards
|
# Provider names rendered as radio cards
|
||||||
assert html =~ "Brevo"
|
assert html =~ "Brevo"
|
||||||
assert html =~ "Mailjet"
|
assert html =~ "Mailjet"
|
||||||
assert html =~ "MailPace"
|
|
||||||
assert html =~ "Postal"
|
assert html =~ "Postal"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows all three provider groups", %{conn: conn} do
|
test "shows providers and advanced section", %{conn: conn} do
|
||||||
{:ok, _view, html} = live(conn, ~p"/admin/settings/email")
|
{:ok, _view, html} = live(conn, ~p"/admin/settings/email")
|
||||||
|
|
||||||
# All-email providers
|
|
||||||
assert html =~ "Popular providers"
|
|
||||||
assert html =~ "Brevo"
|
assert html =~ "Brevo"
|
||||||
assert html =~ "SendGrid"
|
assert html =~ "SendGrid"
|
||||||
assert html =~ "Mailjet"
|
assert html =~ "Mailjet"
|
||||||
assert html =~ "MailerSend"
|
assert html =~ "MailerSend"
|
||||||
|
|
||||||
# Transactional providers
|
|
||||||
assert html =~ "Transactional only"
|
|
||||||
assert html =~ "Resend"
|
|
||||||
assert html =~ "Postmark"
|
|
||||||
assert html =~ "Mailgun"
|
|
||||||
assert html =~ "MailPace"
|
|
||||||
|
|
||||||
# Advanced in details
|
# Advanced in details
|
||||||
assert html =~ "Already have your own email server?"
|
assert html =~ "Already have your own email server?"
|
||||||
assert html =~ "SMTP"
|
assert html =~ "SMTP"
|
||||||
@ -64,10 +54,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 =~ "needs an email provider"
|
assert html =~ "needs an email provider"
|
||||||
assert html =~ "300 emails/day free"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "selecting a provider shows its config fields", %{conn: conn} do
|
test "renders all adapter field sections in the form", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/admin/settings/email")
|
||||||
|
|
||||||
|
# All adapters have their config sections rendered (CSS controls visibility)
|
||||||
|
assert html =~ ~s(data-adapter="brevo")
|
||||||
|
assert html =~ ~s(data-adapter="sendgrid")
|
||||||
|
assert html =~ ~s(data-adapter="mailjet")
|
||||||
|
assert html =~ ~s(data-adapter="smtp")
|
||||||
|
assert html =~ ~s(data-adapter="postal")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "selecting a provider updates via phx-change", %{conn: conn} do
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
||||||
|
|
||||||
# Select SMTP via form change (radio inputs fire phx-change)
|
# Select SMTP via form change (radio inputs fire phx-change)
|
||||||
@ -76,35 +76,9 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|
|||||||
|> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "smtp"}})
|
|> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "smtp"}})
|
||||||
|> render_change()
|
|> render_change()
|
||||||
|
|
||||||
|
# SMTP fields are always rendered; check the step title changes
|
||||||
assert html =~ "Server host"
|
assert html =~ "Server host"
|
||||||
assert html =~ "Port"
|
assert html =~ "Port"
|
||||||
assert html =~ "Username"
|
|
||||||
assert html =~ "Password"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "selecting a different provider shows different fields", %{conn: conn} do
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
|
||||||
|
|
||||||
# Select Brevo which needs just an api_key
|
|
||||||
html =
|
|
||||||
view
|
|
||||||
|> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "brevo"}})
|
|
||||||
|> render_change()
|
|
||||||
|
|
||||||
assert html =~ "API key"
|
|
||||||
assert html =~ "Brevo"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "selecting a transactional provider shows its config", %{conn: conn} do
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
|
||||||
|
|
||||||
html =
|
|
||||||
view
|
|
||||||
|> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "resend"}})
|
|
||||||
|> render_change()
|
|
||||||
|
|
||||||
assert html =~ "API key"
|
|
||||||
assert html =~ "Resend"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "saving config persists settings", %{conn: conn} do
|
test "saving config persists settings", %{conn: conn} do
|
||||||
@ -115,11 +89,11 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|
|||||||
|> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "brevo"}})
|
|> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "brevo"}})
|
||||||
|> render_change()
|
|> render_change()
|
||||||
|
|
||||||
# Submit with an API key
|
# Submit with namespaced fields: email[brevo][api_key]
|
||||||
html =
|
html =
|
||||||
view
|
view
|
||||||
|> form("form[phx-submit=\"save\"]", %{
|
|> form("form[phx-submit=\"save\"]", %{
|
||||||
email: %{adapter: "brevo", api_key: "xkeysib-abc123def456"}
|
email: %{adapter: "brevo", brevo: %{api_key: "xkeysib-abc123def456"}}
|
||||||
})
|
})
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|
|
||||||
@ -138,7 +112,9 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|
|||||||
# Submit without API key
|
# Submit without API key
|
||||||
html =
|
html =
|
||||||
view
|
view
|
||||||
|> form("form[phx-submit=\"save\"]", %{email: %{adapter: "brevo", api_key: ""}})
|
|> form("form[phx-submit=\"save\"]", %{
|
||||||
|
email: %{adapter: "brevo", brevo: %{api_key: ""}}
|
||||||
|
})
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|
|
||||||
assert html =~ "API key is required"
|
assert html =~ "API key is required"
|
||||||
@ -154,7 +130,7 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|
|||||||
html =
|
html =
|
||||||
view
|
view
|
||||||
|> form("form[phx-submit=\"save\"]", %{
|
|> form("form[phx-submit=\"save\"]", %{
|
||||||
email: %{adapter: "smtp", relay: "smtp.example.com", port: "abc"}
|
email: %{adapter: "smtp", smtp: %{relay: "smtp.example.com", port: "abc"}}
|
||||||
})
|
})
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|
|
||||||
@ -162,8 +138,8 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|
|||||||
end
|
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", "brevo")
|
||||||
Settings.put_secret("email_postmark_api_key", "pm_test_abc")
|
Settings.put_secret("email_brevo_api_key", "xkeysib-test-abc")
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, ~p"/admin/settings/email")
|
{:ok, _view, html} = live(conn, ~p"/admin/settings/email")
|
||||||
|
|
||||||
@ -182,8 +158,8 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "sending test email shows success and 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", "brevo")
|
||||||
Settings.put_secret("email_postmark_api_key", "pm_test_abc")
|
Settings.put_secret("email_brevo_api_key", "xkeysib-test-abc")
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
||||||
|
|
||||||
@ -205,14 +181,14 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|
|||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
{:ok, view, _html} = live(conn, ~p"/admin/settings/email")
|
||||||
|
|
||||||
# Switch to Brevo and save
|
# Switch to Brevo and save (namespaced fields)
|
||||||
view
|
view
|
||||||
|> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "brevo"}})
|
|> form("form[phx-change=\"form_change\"]", %{email: %{adapter: "brevo"}})
|
||||||
|> render_change()
|
|> render_change()
|
||||||
|
|
||||||
view
|
view
|
||||||
|> form("form[phx-submit=\"save\"]", %{
|
|> form("form[phx-submit=\"save\"]", %{
|
||||||
email: %{adapter: "brevo", api_key: "xkeysib-switch-test"}
|
email: %{adapter: "brevo", brevo: %{api_key: "xkeysib-switch-test"}}
|
||||||
})
|
})
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|
|
||||||
@ -236,7 +212,7 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|
|||||||
|
|
||||||
view
|
view
|
||||||
|> form("form[phx-submit=\"save\"]", %{
|
|> form("form[phx-submit=\"save\"]", %{
|
||||||
email: %{adapter: "brevo", api_key: "xkeysib-def789ghi012"}
|
email: %{adapter: "brevo", brevo: %{api_key: "xkeysib-def789ghi012"}}
|
||||||
})
|
})
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|
|
||||||
@ -262,13 +238,6 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do
|
|||||||
assert html =~ "API key"
|
assert html =~ "API key"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "adapter query param preselects provider", %{conn: conn} do
|
|
||||||
{:ok, _view, html} = live(conn, ~p"/admin/settings/email?adapter=resend")
|
|
||||||
|
|
||||||
assert html =~ "Resend"
|
|
||||||
assert html =~ "API key"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "from_checklist param shows checklist banner", %{conn: conn} do
|
test "from_checklist param shows checklist banner", %{conn: conn} do
|
||||||
{:ok, _view, html} = live(conn, ~p"/admin/settings/email?from=checklist")
|
{:ok, _view, html} = live(conn, ~p"/admin/settings/email?from=checklist")
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user