From 7547d0d4b8ec976bb24239215b6dc5e29d259545 Mon Sep 17 00:00:00 2001 From: jamey Date: Wed, 4 Mar 2026 17:12:10 +0000 Subject: [PATCH] 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 --- assets/css/admin/components.css | 140 +++++- lib/berrypod/key_validation.ex | 150 +++++-- lib/berrypod/mailer.ex | 95 +++++ lib/berrypod/mailer/adapter.ex | 16 +- lib/berrypod/mailer/adapters.ex | 102 +++-- lib/berrypod_web/live/admin/email_settings.ex | 398 +++++++++++------- lib/berrypod_web/live/admin/settings.ex | 37 ++ test/berrypod/key_validation_test.exs | 92 +++- test/berrypod/mailer_test.exs | 119 ++++++ .../live/admin/email_settings_test.exs | 98 ++--- .../berrypod_web/live/admin/settings_test.exs | 26 ++ 11 files changed, 1004 insertions(+), 269 deletions(-) diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index ec7b6b9..c49fdc0 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -1277,11 +1277,27 @@ 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 { display: flex; padding: 0.5rem; cursor: pointer; width: 33.333%; + z-index: 1; + opacity: 0.6; + + &[aria-pressed="true"] { + opacity: 1; + } } /* ── Setup page ── */ @@ -1290,11 +1306,26 @@ max-width: 36rem; margin: 0 auto; padding: 2rem 1rem; + display: flex; + flex-direction: column; + gap: 1rem; } .setup-header { 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 { @@ -4206,23 +4237,120 @@ 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 ── */ .admin-adapter-config { margin-top: 1.5rem; display: flex; flex-direction: column; - gap: 1rem; + gap: 1.5rem; &[hidden] { display: none; } } -.admin-adapter-link { - font-weight: 400; -} - /* ── Campaign form ── */ .admin-campaign-actions { diff --git a/lib/berrypod/key_validation.ex b/lib/berrypod/key_validation.ex index 21b183c..cd99b0a 100644 --- a/lib/berrypod/key_validation.ex +++ b/lib/berrypod/key_validation.ex @@ -80,18 +80,21 @@ defmodule Berrypod.KeyValidation do # ── Email provider format checks ── + # Known prefixes for cross-provider detection + @known_prefixes [ + {"SG.", "SendGrid"}, + {"xkeysib-", "Brevo"}, + {"re_", "Resend"}, + {"key-", "Mailgun"}, + {"mlsn.", "MailerSend"} + ] + # SendGrid: SG.{id}.{secret} defp validate_email_format(key, "sendgrid", "api_key") do cond do - String.starts_with?(key, "SG.") -> - {:ok, key} - - String.contains?(key, ".") -> - {:error, "SendGrid keys start with SG. Check you copied the full key"} - - true -> - {:error, - "SendGrid API keys start with SG. (like SG.xxxxx.xxxxx). This doesn't look right"} + String.starts_with?(key, "SG.") -> {:ok, key} + wrong = wrong_provider_hint(key, "sendgrid") -> {:error, wrong} + true -> {:error, "SendGrid API keys start with SG. — find yours in Settings → API Keys"} end end @@ -99,56 +102,147 @@ defmodule Berrypod.KeyValidation do defp validate_email_format(key, "postmark", "api_key") do uuid_pattern = ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - if Regex.match?(uuid_pattern, key) do - {:ok, key} - else - {:error, - "Postmark tokens are UUIDs (like abc12345-abcd-1234-abcd-123456789abc). Check you copied the server token, not the account token ID"} + cond do + Regex.match?(uuid_pattern, key) -> + {:ok, key} + + wrong = wrong_provider_hint(key, "postmark") -> + {:error, wrong} + + true -> + {:error, + "Postmark server tokens are UUIDs (like abc12345-abcd-1234-abcd-123456789abc). Make sure you copy the server token, not the account ID"} end end # Resend: re_ prefix defp validate_email_format(key, "resend", "api_key") do - if String.starts_with?(key, "re_") do - {:ok, key} - else - {:error, "Resend API keys start with re_. Check you copied the full key"} + cond do + String.starts_with?(key, "re_") -> + {:ok, key} + + wrong = wrong_provider_hint(key, "resend") -> + {:error, wrong} + + true -> + {:error, + "Resend API keys start with re_ — find yours in your Resend dashboard under API Keys"} end end - # Mailgun: key- prefix (classic keys) + # Mailgun: key- prefix (classic) or long RBAC keys defp validate_email_format(key, "mailgun", "api_key") do cond do String.starts_with?(key, "key-") -> {:ok, key} - # Newer RBAC keys don't have a known prefix, let them through String.length(key) >= 20 -> {:ok, key} + wrong = wrong_provider_hint(key, "mailgun") -> + {:error, wrong} + true -> - {:error, "Mailgun API keys usually start with key-. Check you copied the full key"} + {:error, + "Mailgun API keys usually start with key- — find yours in Settings → API Security"} end end # Brevo: xkeysib- prefix defp validate_email_format(key, "brevo", "api_key") do - if String.starts_with?(key, "xkeysib-") do - {:ok, key} - else - {:error, "Brevo API keys start with xkeysib-. Check you copied the full key"} + cond do + String.starts_with?(key, "xkeysib-") -> + {:ok, key} + + wrong = wrong_provider_hint(key, "brevo") -> + {:error, wrong} + + true -> + {:error, + "Brevo API keys start with xkeysib- — find yours under SMTP & API in your Brevo account"} end end - # Unknown provider or non-api_key field, basic length check only + # Mailjet: api_key and secret are both 32-char alphanumeric + defp validate_email_format(key, "mailjet", "api_key") do + cond do + String.length(key) >= 20 -> + {:ok, key} + + wrong = wrong_provider_hint(key, "mailjet") -> + {:error, wrong} + + true -> + {:error, + "This looks too short for a Mailjet API key — find yours under API Key Management in your Mailjet account"} + end + end + + defp validate_email_format(key, "mailjet", "secret") do + cond do + String.length(key) >= 20 -> + {:ok, key} + + wrong = wrong_provider_hint(key, "mailjet") -> + {:error, wrong} + + true -> + {:error, + "This looks too short for a Mailjet secret key — it's shown alongside your API key in API Key Management"} + end + end + + # MailerSend: mlsn. prefix + defp validate_email_format(key, "mailersend", "api_key") do + cond do + String.starts_with?(key, "mlsn.") -> + {:ok, key} + + String.length(key) >= 40 -> + {:ok, key} + + wrong = wrong_provider_hint(key, "mailersend") -> + {:error, wrong} + + true -> + {:error, + "MailerSend API tokens usually start with mlsn. — generate one under Domains → Manage → API Tokens"} + end + end + + # MailPace: server token, no known prefix + defp validate_email_format(key, "mailpace", "api_key") do + cond do + String.length(key) >= 10 -> + {:ok, key} + + wrong = wrong_provider_hint(key, "mailpace") -> + {:error, wrong} + + true -> + {:error, + "This looks too short — find your server API token under your domain settings in MailPace"} + end + end + + # Non-api_key fields (domain, relay, base_url, etc.), basic checks defp validate_email_format(key, _adapter_key, _field_key) do - if String.length(key) < 5 do - {:error, "This value looks too short. Check you copied the full key"} + if String.length(key) < 3 do + {:error, "This value looks too short"} else {:ok, key} end end + # Detects when a key meant for another provider is pasted into the wrong field + defp wrong_provider_hint(key, current_adapter) do + Enum.find_value(@known_prefixes, fn {prefix, name} -> + if String.starts_with?(key, prefix) and current_adapter != String.downcase(name) do + "This looks like a #{name} key, not the right one for this provider" + end + end) + end + # ── Helpers ── defp trim(nil), do: "" diff --git a/lib/berrypod/mailer.ex b/lib/berrypod/mailer.ex index 118ab1f..dc69506 100644 --- a/lib/berrypod/mailer.ex +++ b/lib/berrypod/mailer.ex @@ -132,6 +132,101 @@ defmodule Berrypod.Mailer do Settings.get_setting("email_from_address") || "noreply@#{default_from_domain()}" end + @doc """ + Turns a raw delivery error into a user-friendly message. + + Swoosh adapters return varied error shapes — HTTP status tuples, + SMTP failure atoms, network errors, etc. This normalises them into + a single readable string for the admin UI. + """ + def friendly_error({status, body}) when is_integer(status) do + message = extract_message(body) + + case status do + 401 -> + "Your API key was rejected" <> + if(message, do: " — #{message}", else: ". Double-check you pasted the right key") + + 403 -> + "Your API key doesn't have permission to send email" <> + if(message, do: " — #{message}", else: "") + + 404 -> + "Provider endpoint not found" <> + if(message, do: " — #{message}", else: ". Check your config") + + 422 -> + "The provider rejected the request" <> if(message, do: " — #{message}", else: "") + + 429 -> + "Rate limit reached. Wait a moment and try again" + + status when status >= 500 -> + "The email provider is having issues (#{status}). Try again in a few minutes" + + _ -> + "Provider returned error #{status}" <> if(message, do: " — #{message}", else: "") + end + end + + # SMTP errors: {:permanent_failure, message} or {:temporary_failure, message} + def friendly_error({:permanent_failure, msg}) when is_binary(msg), + do: "SMTP server rejected the email: #{truncate(msg)}" + + def friendly_error({:temporary_failure, msg}) when is_binary(msg), + do: "SMTP server temporarily unavailable: #{truncate(msg)}" + + def friendly_error({:retries_exceeded, _}), + do: "SMTP connection failed after retries. Check your server address and port" + + # SMTP connection errors + def friendly_error({:no_more_hosts, _}), + do: "Couldn't connect to the SMTP server. Check the hostname and port" + + def friendly_error({:auth_failed, _}), do: "SMTP login failed. Check your username and password" + + # Network-level errors + def friendly_error(:timeout), do: "Connection timed out. Check your internet connection" + def friendly_error(:econnrefused), do: "Connection refused. Check the server address and port" + def friendly_error(:nxdomain), do: "Server hostname not found. Check the address" + def friendly_error(:closed), do: "Connection was closed. The server may be down" + + # Catch-all + def friendly_error(other) when is_binary(other), do: other + def friendly_error(other), do: "Delivery failed: #{inspect(other)}" + + @doc """ + Returns whether a delivery error is transient (worth retrying). + + Config errors (bad key, wrong permissions) need a fix, not a retry. + Transient errors (rate limits, timeouts, server issues) may resolve on their own. + """ + def retryable_error?({status, _}) when is_integer(status), do: status in [429] or status >= 500 + def retryable_error?({:temporary_failure, _}), do: true + def retryable_error?({:retries_exceeded, _}), do: true + def retryable_error?(:timeout), do: true + def retryable_error?(:closed), do: true + def retryable_error?(_), do: false + + # Extract a human-readable message from various API error body shapes + defp extract_message(%{"message" => msg}) when is_binary(msg), do: downcase_first(msg) + defp extract_message(%{"ErrorMessage" => msg}) when is_binary(msg), do: downcase_first(msg) + + defp extract_message(%{"error" => %{"message" => msg}}) when is_binary(msg), + do: downcase_first(msg) + + defp extract_message(%{"errors" => [%{"message" => msg} | _]}) when is_binary(msg), + do: downcase_first(msg) + + defp extract_message(body) when is_binary(body) and byte_size(body) < 200, do: body + defp extract_message(_), do: nil + + defp downcase_first(<>), do: String.downcase(<>) <> rest + defp downcase_first(str), do: str + + defp truncate(msg) when byte_size(msg) > 120, do: binary_part(msg, 0, 120) <> "..." + defp truncate(msg), do: msg + # Build Swoosh config keyword list from Settings for a given adapter defp build_config(adapter_info) do opts = diff --git a/lib/berrypod/mailer/adapter.ex b/lib/berrypod/mailer/adapter.ex index ac9f61b..d05fe0c 100644 --- a/lib/berrypod/mailer/adapter.ex +++ b/lib/berrypod/mailer/adapter.ex @@ -1,5 +1,17 @@ defmodule Berrypod.Mailer.Adapter do @moduledoc false - @enforce_keys [:key, :name, :module, :description, :tags, :fields] - defstruct [:key, :name, :module, :description, :tags, :fields, url: nil] + @enforce_keys [:key, :name, :module, :description, :fields] + defstruct [ + :key, + :name, + :module, + :description, + :fields, + :free_tier, + :setup_hint, + tags: [], + url: nil, + category: :all_email, + recommended: false + ] end diff --git a/lib/berrypod/mailer/adapters.ex b/lib/berrypod/mailer/adapters.ex index 9cf930f..0d2b483 100644 --- a/lib/berrypod/mailer/adapters.ex +++ b/lib/berrypod/mailer/adapters.ex @@ -9,20 +9,21 @@ defmodule Berrypod.Mailer.Adapters do alias Berrypod.Mailer.{Adapter, Field} - # Ordered by capability: transactional + marketing first, - # then transactional only, then self-hosted. @adapters [ + # ── Also sends newsletters ── %Adapter{ - key: "smtp", - name: "SMTP", - module: Swoosh.Adapters.SMTP, - description: "Connect to any email server via SMTP. Works with most providers and hosts.", - tags: ["All email", "Any provider"], + key: "brevo", + name: "Brevo", + module: Swoosh.Adapters.Brevo, + description: "All-in-one platform, GDPR-friendly.", + tags: ["All email", "France", "GDPR"], + category: :all_email, + recommended: true, + free_tier: "300 emails/day free", + setup_hint: "Paste one API key", + url: "https://www.brevo.com", fields: [ - %Field{key: "relay", label: "Server host", type: :string, required: true}, - %Field{key: "port", label: "Port", type: :integer, default: 587}, - %Field{key: "username", label: "Username", type: :string}, - %Field{key: "password", label: "Password", type: :secret} + %Field{key: "api_key", label: "API key", type: :secret, required: true} ] }, %Adapter{ @@ -31,28 +32,23 @@ defmodule Berrypod.Mailer.Adapters do module: Swoosh.Adapters.Sendgrid, description: "Generous free tier, widely used.", tags: ["All email", "US"], + category: :all_email, + free_tier: "100 emails/day free", + setup_hint: "Paste one API key", url: "https://sendgrid.com", fields: [ %Field{key: "api_key", label: "API key", type: :secret, required: true} ] }, - %Adapter{ - key: "brevo", - name: "Brevo", - module: Swoosh.Adapters.Brevo, - description: "All-in-one platform, GDPR-friendly.", - tags: ["All email", "France", "GDPR"], - url: "https://www.brevo.com", - fields: [ - %Field{key: "api_key", label: "API key", type: :secret, required: true} - ] - }, %Adapter{ key: "mailjet", name: "Mailjet", module: Swoosh.Adapters.Mailjet, description: "EU data processing, good free tier.", tags: ["All email", "France", "GDPR"], + category: :all_email, + free_tier: "200 emails/day free", + setup_hint: "Two API keys needed", url: "https://www.mailjet.com", fields: [ %Field{key: "api_key", label: "API key", type: :secret, required: true}, @@ -65,62 +61,97 @@ defmodule Berrypod.Mailer.Adapters do module: Swoosh.Adapters.MailerSend, description: "Generous free tier, good analytics dashboard.", tags: ["All email", "EU option"], + category: :all_email, + free_tier: "3,000 emails/month free", + setup_hint: "Paste one API key", url: "https://www.mailersend.com", fields: [ %Field{key: "api_key", label: "API key", type: :secret, required: true} ] }, + # ── Transactional only ── %Adapter{ key: "resend", name: "Resend", module: Swoosh.Adapters.Resend, description: "Developer-friendly API, simple setup.", tags: ["Transactional", "US"], + category: :transactional, + free_tier: "3,000 emails/month free", + setup_hint: "Paste one API key", url: "https://resend.com", fields: [ %Field{key: "api_key", label: "API key", type: :secret, required: true} ] }, + %Adapter{ + key: "postmark", + name: "Postmark", + module: Swoosh.Adapters.Postmark, + description: "Excellent deliverability tracking.", + tags: ["Transactional", "US"], + category: :transactional, + free_tier: "100 emails/month free", + setup_hint: "Paste one API key", + url: "https://postmarkapp.com", + fields: [ + %Field{key: "api_key", label: "API key", type: :secret, required: true} + ] + }, %Adapter{ key: "mailgun", name: "Mailgun", module: Swoosh.Adapters.Mailgun, description: "EU region option available.", tags: ["Transactional", "EU option", "Sweden"], + category: :transactional, + free_tier: "100 emails/day trial", + setup_hint: "API key + domain name", url: "https://www.mailgun.com", fields: [ %Field{key: "api_key", label: "API key", type: :secret, required: true}, %Field{key: "domain", label: "Domain", type: :string, required: true} ] }, - %Adapter{ - key: "postmark", - name: "Postmark", - module: Swoosh.Adapters.Postmark, - description: "Excellent deliverability tracking.", - tags: ["Transactional", "US"], - url: "https://postmarkapp.com", - fields: [ - %Field{key: "api_key", label: "API key", type: :secret, required: true} - ] - }, %Adapter{ key: "mailpace", name: "MailPace", module: Swoosh.Adapters.MailPace, description: "Privacy-focused, simple API.", tags: ["Transactional", "UK"], + category: :transactional, + free_tier: "3,000 emails/month free", + setup_hint: "Paste one API key", url: "https://mailpace.com", fields: [ %Field{key: "api_key", label: "API key", type: :secret, required: true} ] }, + # ── Advanced ── + %Adapter{ + key: "smtp", + name: "SMTP", + module: Swoosh.Adapters.SMTP, + description: "Connect to any email server via SMTP.", + tags: ["All email", "Any provider"], + category: :advanced, + setup_hint: "Server details needed", + fields: [ + %Field{key: "relay", label: "Server host", type: :string, required: true}, + %Field{key: "port", label: "Port", type: :integer, default: 587}, + %Field{key: "username", label: "Username", type: :string}, + %Field{key: "password", label: "Password", type: :secret} + ] + }, %Adapter{ key: "postal", name: "Postal", module: Swoosh.Adapters.Postal, description: "Full control over your email infrastructure.", tags: ["All email", "Self-hosted", "Open source"], + category: :advanced, + free_tier: "Self-hosted, no limits", + setup_hint: "Requires your own server", url: "https://docs.postalserver.io", fields: [ %Field{key: "api_key", label: "API key", type: :secret, required: true}, @@ -132,6 +163,11 @@ defmodule Berrypod.Mailer.Adapters do @doc "Returns all supported adapters." def all, do: @adapters + @doc "Returns adapters grouped by category." + def grouped do + Enum.group_by(@adapters, & &1.category) + end + @doc "Returns an adapter by its string key, or nil." def get(key) when is_binary(key) do Enum.find(@adapters, &(&1.key == key)) diff --git a/lib/berrypod_web/live/admin/email_settings.ex b/lib/berrypod_web/live/admin/email_settings.ex index 35dd4e8..2ff39ac 100644 --- a/lib/berrypod_web/live/admin/email_settings.ex +++ b/lib/berrypod_web/live/admin/email_settings.ex @@ -13,6 +13,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do saved_adapter = Settings.get_setting("email_adapter") adapter_key = current_adapter || saved_adapter + grouped = Adapters.grouped() {:ok, socket @@ -21,13 +22,14 @@ defmodule BerrypodWeb.Admin.EmailSettings do |> assign(:adapter_key, adapter_key) |> assign(:current_values, current_values) |> 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( - :from_address, - Settings.get_setting("email_from_address") || socket.assigns.current_scope.user.email - ) + |> assign(:selected_adapter, adapter_key && Adapters.get(adapter_key)) |> 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(:field_errors, %{}) |> assign(:form, to_form(%{}, as: :email))} @@ -58,18 +60,6 @@ defmodule BerrypodWeb.Admin.EmailSettings do 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 def handle_event("change_adapter", %{"email" => %{"adapter" => key}}, socket) do values = load_adapter_values(key) @@ -77,8 +67,11 @@ defmodule BerrypodWeb.Admin.EmailSettings do {:noreply, socket |> assign(:adapter_key, key) + |> assign(:selected_adapter, Adapters.get(key)) |> assign(:current_values, values) - |> assign(:field_errors, %{})} + |> assign(:field_errors, %{}) + |> assign(:test_result, nil) + |> assign(:test_error, nil)} end def handle_event("save", %{"email" => params}, socket) do @@ -96,50 +89,28 @@ defmodule BerrypodWeb.Admin.EmailSettings do 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 user = socket.assigns.current_scope.user 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, _} -> Mailer.mark_email_verified() {:noreply, socket |> assign(:sending_test, false) - |> put_flash(:info, "Test email sent to #{user.email}")} + |> assign(:test_result, :ok) + |> assign(:test_error, nil)} {:error, reason} -> {:noreply, socket |> 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 @@ -183,6 +154,13 @@ defmodule BerrypodWeb.Admin.EmailSettings do if field_errors != %{} do {:noreply, assign(socket, :field_errors, field_errors)} 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 Settings.put_setting("email_adapter", adapter_info.key) @@ -207,11 +185,9 @@ defmodule BerrypodWeb.Admin.EmailSettings do end end - # Save from address - from_address = params["from_address"] || "" - - if from_address != "" do - Settings.put_setting("email_from_address", from_address) + # Auto-set from address to admin email if not already configured + if Settings.get_setting("email_from_address") in [nil, ""] do + Settings.put_setting("email_from_address", socket.assigns.current_scope.user.email) end # Config changed — require re-verification @@ -226,11 +202,13 @@ defmodule BerrypodWeb.Admin.EmailSettings do {:noreply, socket |> assign(:adapter_key, current_adapter) + |> assign(:selected_adapter, Adapters.get(current_adapter)) |> assign(:current_values, current_values) - |> assign(:from_address, from_address) |> assign(:email_configured, Mailer.email_configured?()) |> 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 @@ -250,9 +228,8 @@ defmodule BerrypodWeb.Admin.EmailSettings do <.header> Email settings <:subtitle> - Configure how your shop sends email. Transactional - providers only handle order confirmations and password resets. All email - providers also support newsletters and marketing campaigns. + Your shop needs an email provider to send order confirmations, + shipping updates, and newsletters to your customers. @@ -277,17 +254,44 @@ defmodule BerrypodWeb.Admin.EmailSettings do
<.form for={@form} phx-change="change_adapter" phx-submit="save"> -
- <.card_radio_group - name="email[adapter]" - value={@adapter_key} - legend="Email provider" - options={@provider_options} - disabled={@env_locked} - display={:tags} - /> + <%!-- Step 1: Choose a provider --%> +
+
+ 1 +

Choose a provider

+
+

+ All of these have a free tier. Pick whichever you like. +

+
+
+
+ <.provider_card + :for={adapter <- @recommended_adapters} + adapter={adapter} + selected={@adapter_key} + disabled={@env_locked} + /> +
+ +
+ + Already have your own email server? + +
+ <.provider_card + :for={adapter <- @advanced_adapters} + adapter={adapter} + selected={@adapter_key} + disabled={@env_locked} + /> +
+
+
+
+ <%!-- Steps 2 & 3 appear per-adapter after selection --%> <%= for adapter <- @all_adapters do %> <% selected = @adapter_key == adapter.key %>
- <%= if @email_configured do %> -
-

Test email

-

- Send a test email to {@current_scope.user.email} - to verify delivery works. + <%!-- Step 4: Send a test email (only after config saved) --%> +

+
+ + <%= 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 %> + +

+ <%= cond do %> + <% @test_result == :ok -> %> + Email is working + <% @test_result == :error -> %> + Test failed + <% true -> %> + Send a test email + <% end %> +

+
+ + <%= if @test_result == :ok do %> +

+ Test email sent to {@current_scope.user.email}. + Check your inbox to confirm it arrived.

-
- + Continue setup → + + <.button type="button" phx-click="send_test"> + Send again +
-
- <% end %> + <% else %> + <%= if @test_result == :error do %> +

{@test_error}

+
+ <%= if @test_retryable do %> + + <% else %> +

+ Fix your settings above and reconnect, then try the test again. +

+ <% end %> +
+ <% else %> +

+ Send a test to {@current_scope.user.email} to check everything works. +

+
+ +
+ <% end %> + <% 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""" + + """ + end + + # ── Field renderers ── + attr :field_def, :map, required: true attr :value, :any, default: nil 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: [])) ~H""" -
- <.input - name={"email[#{@field_def.key}]"} - value="" - type="text" - label={@field_def.label} - autocomplete="off" - placeholder={if @value, do: @value, else: ""} - required={@field_def.required && !@value} - disabled={@disabled} - errors={@errors} - /> - <%= if @value && !@disabled do %> -

- Current: {@value} — leave blank to keep existing value -

- <% end %> -
+ <.input + name={"email[#{@field_def.key}]"} + value="" + type="text" + label={@field_def.label} + autocomplete="off" + placeholder={@value || ""} + required={@field_def.required && !@value} + disabled={@disabled} + errors={@errors} + /> """ end diff --git a/lib/berrypod_web/live/admin/settings.ex b/lib/berrypod_web/live/admin/settings.ex index 8eb1c28..0e1c6b5 100644 --- a/lib/berrypod_web/live/admin/settings.ex +++ b/lib/berrypod_web/live/admin/settings.ex @@ -15,6 +15,7 @@ defmodule BerrypodWeb.Admin.Settings do |> assign(:page_title, "Settings") |> assign(:site_live, Settings.site_live?()) |> assign(:cart_recovery_enabled, Settings.abandoned_cart_recovery_enabled?()) + |> assign(:from_address, Settings.get_setting("email_from_address") || user.email) |> assign_stripe_state() |> assign_products_state() |> assign_account_state(user)} @@ -108,6 +109,23 @@ defmodule BerrypodWeb.Admin.Settings do |> put_flash(:info, message)} 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 -- def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do @@ -415,6 +433,25 @@ defmodule BerrypodWeb.Admin.Settings do + <%!-- From address --%> +
+

From address

+

+ The sender address on all emails from your shop. +

+
+
+ <.input + name="from_address" + value={@from_address} + type="email" + placeholder="noreply@yourshop.com" + /> + <.button phx-disable-with="Saving...">Save +
+
+
+ <%!-- Account --%>

Account

diff --git a/test/berrypod/key_validation_test.exs b/test/berrypod/key_validation_test.exs index fb7e70d..b4831d9 100644 --- a/test/berrypod/key_validation_test.exs +++ b/test/berrypod/key_validation_test.exs @@ -170,19 +170,99 @@ defmodule Berrypod.KeyValidationTest do end end - describe "validate_email_key/3 - unknown providers" do - test "accepts reasonable key for unknown adapter" do - assert {:ok, "some_api_key_12345"} = - KeyValidation.validate_email_key("some_api_key_12345", "mailersend", "api_key") + describe "validate_email_key/3 - Mailjet" do + test "accepts valid api_key" do + key = String.duplicate("a1b2c3d4", 4) + assert {:ok, ^key} = KeyValidation.validate_email_key(key, "mailjet", "api_key") 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"} = KeyValidation.validate_email_key("smtp.example.com", "smtp", "relay") end 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" end diff --git a/test/berrypod/mailer_test.exs b/test/berrypod/mailer_test.exs index 0802bbc..d0c2cce 100644 --- a/test/berrypod/mailer_test.exs +++ b/test/berrypod/mailer_test.exs @@ -88,6 +88,125 @@ defmodule Berrypod.MailerTest do 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 test "returns {nil, %{}} when no adapter configured" do Application.put_env(:berrypod, Mailer, adapter: Swoosh.Adapters.Local) diff --git a/test/berrypod_web/live/admin/email_settings_test.exs b/test/berrypod_web/live/admin/email_settings_test.exs index c07786f..57b95db 100644 --- a/test/berrypod_web/live/admin/email_settings_test.exs +++ b/test/berrypod_web/live/admin/email_settings_test.exs @@ -29,21 +29,20 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do {:ok, _view, html} = live(conn, ~p"/admin/settings/email") assert html =~ "Email settings" - assert html =~ "Email provider" + assert html =~ "Choose a provider" # Provider names rendered as radio cards - assert html =~ "Postmark" assert html =~ "Brevo" assert html =~ "Mailjet" assert html =~ "MailPace" assert html =~ "Postal" end - test "shows provider descriptions", %{conn: conn} do + test "shows setup guidance", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/admin/settings/email") - assert html =~ "Excellent deliverability tracking" - assert html =~ "All-in-one platform, GDPR-friendly" - assert html =~ "EU data processing" + assert html =~ "needs an email provider" + assert html =~ "Paste your API key" + assert html =~ "300 emails/day free" end 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 {: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 = view - |> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "mailgun"}}) + |> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "brevo"}}) |> render_change() assert html =~ "API key" - assert html =~ "Domain" + assert html =~ "Brevo" end test "saving config persists settings", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/admin/settings/email") - # Select Postmark via form change + # Select Brevo via form change view - |> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "postmark"}}) + |> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "brevo"}}) |> render_change() - # Submit with an API key (Postmark uses UUID format) + # Submit with an API key html = view |> form("form[phx-submit=\"save\"]", %{ - email: %{adapter: "postmark", api_key: "abc12345-abcd-1234-abcd-123456789abc"} + email: %{adapter: "brevo", api_key: "xkeysib-abc123def456"} }) |> render_submit() - assert html =~ "Email settings saved" - assert Settings.get_setting("email_adapter") == "postmark" + assert html =~ "Settings saved" + assert Settings.get_setting("email_adapter") == "brevo" end test "saving without required fields shows error", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/admin/settings/email") - # Select Postmark + # Select Brevo view - |> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "postmark"}}) + |> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "brevo"}}) |> render_change() # Submit without API key html = view - |> form("form[phx-submit=\"save\"]", %{email: %{adapter: "postmark", api_key: ""}}) + |> form("form[phx-submit=\"save\"]", %{email: %{adapter: "brevo", api_key: ""}}) |> render_submit() assert html =~ "API key is required" 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 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") - assert html =~ "Test email" + assert html =~ "Send a test email" assert html =~ "Send test email" end @@ -143,7 +130,7 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do refute html =~ "Send test email" 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_secret("email_postmark_api_key", "pm_test_abc") @@ -151,10 +138,38 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do html = render_click(view, "send_test") - assert html =~ "Test email sent" + assert html =~ "Email is working" + assert html =~ "Send again" assert Mailer.email_verified?() 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 Mailer.mark_email_verified() assert Mailer.email_verified?() @@ -162,30 +177,17 @@ defmodule BerrypodWeb.Admin.EmailSettingsTest do {:ok, view, _html} = live(conn, ~p"/admin/settings/email") view - |> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "postmark"}}) + |> form("form[phx-change=\"change_adapter\"]", %{email: %{adapter: "brevo"}}) |> render_change() view |> form("form[phx-submit=\"save\"]", %{ - email: %{adapter: "postmark", api_key: "def12345-abcd-1234-abcd-123456789def"} + email: %{adapter: "brevo", api_key: "xkeysib-def789ghi012"} }) |> render_submit() refute Mailer.email_verified?() 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 describe "unauthenticated" do diff --git a/test/berrypod_web/live/admin/settings_test.exs b/test/berrypod_web/live/admin/settings_test.exs index 930e7d7..bccb3f5 100644 --- a/test/berrypod_web/live/admin/settings_test.exs +++ b/test/berrypod_web/live/admin/settings_test.exs @@ -240,6 +240,32 @@ defmodule BerrypodWeb.Admin.SettingsTest do 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 setup %{conn: conn, user: user} do conn = log_in_user(conn, user)