From c4135308e69be6536c9f17395cba677759ca5666 Mon Sep 17 00:00:00 2001
From: Simon
Date: Wed, 11 Mar 2026 09:18:37 +0100
Subject: [PATCH 1/4] test: add tests for smtp mailer config
---
docs/feature-roadmap.md | 2 +
docs/smtp-configuration-concept.md | 101 ++++++++++++++
lib/mv/config.ex | 38 ++++++
lib/mv/mailer.ex | 11 ++
test/membership/setting_smtp_test.exs | 63 +++++++++
test/mv/config_smtp_test.exs | 129 ++++++++++++++++++
test/mv/mailer_test.exs | 46 +++++++
.../mv_web/live/global_settings_live_test.exs | 48 +++++++
test/mv_web/live/join_live_test.exs | 2 +
9 files changed, 440 insertions(+)
create mode 100644 docs/smtp-configuration-concept.md
create mode 100644 test/membership/setting_smtp_test.exs
create mode 100644 test/mv/config_smtp_test.exs
create mode 100644 test/mv/mailer_test.exs
diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md
index 89c2f39..f3b1e27 100644
--- a/docs/feature-roadmap.md
+++ b/docs/feature-roadmap.md
@@ -271,6 +271,7 @@
- [#186](https://git.local-it.org/local-it/mitgliederverwaltung/issues/186) - Create Architecture docs in Repo (S, Low priority)
**Missing Features:**
+- ❌ **SMTP configuration** – Configure mail server via ENV and Admin Settings, test email from Settings. See [`docs/smtp-configuration-concept.md`](smtp-configuration-concept.md).
- ❌ Email templates configuration
- ❌ System health dashboard
- ❌ Audit log viewer
@@ -287,6 +288,7 @@
- ✅ Swoosh mailer integration
- ✅ Email confirmation (via AshAuthentication)
- ✅ Password reset emails (via AshAuthentication)
+- ⚠️ No SMTP configuration (mailer uses Local/Test adapter; prod not configured)
- ⚠️ No member communication features
**Missing Features:**
diff --git a/docs/smtp-configuration-concept.md b/docs/smtp-configuration-concept.md
new file mode 100644
index 0000000..b0ca8cc
--- /dev/null
+++ b/docs/smtp-configuration-concept.md
@@ -0,0 +1,101 @@
+# SMTP Configuration – Concept
+
+**Status:** Draft
+**Last updated:** 2026-03-11
+
+---
+
+## 1. Goal
+
+Enable configurable SMTP for sending transactional emails (join confirmation, user confirmation, password reset). Configuration via **environment variables** and **Admin Settings** (database), with the same precedence pattern as OIDC and Vereinfacht: **ENV overrides Settings**. Include a **test email** action in Settings (button + recipient field) with clear success/error feedback.
+
+---
+
+## 2. Scope
+
+- **In scope:** SMTP server configuration (host, port, credentials, TLS/SSL), test email from Settings UI, warning when SMTP is not configured in production.
+- **Out of scope:** Changing how AshAuthentication or existing senders use the mailer; they keep using `Mv.Mailer` and `mail_from/0`. No separate "form_mail" config – the existing **mail_from** (MAIL_FROM_NAME, MAIL_FROM_EMAIL) remains the single sender identity for all transactional mails.
+
+---
+
+## 3. Configuration Sources
+
+| Source | Priority | Use case |
+|----------|----------|-----------------------------------|
+| ENV | 1 | Production, Docker, 12-factor |
+| Settings | 2 | Admin UI, dev without ENV |
+
+When an ENV variable is set, the corresponding Settings field is read-only in the UI (with hint "Set by environment").
+
+---
+
+## 4. SMTP Parameters
+
+| Parameter | ENV | Settings attribute | Notes |
+|------------|------------------------|--------------------|--------------------------------------------|
+| Host | `SMTP_HOST` | `smtp_host` | e.g. `smtp.example.com` |
+| Port | `SMTP_PORT` | `smtp_port` | Default 587 (TLS), 465 (SSL), 25 (plain) |
+| Username | `SMTP_USERNAME` | `smtp_username` | Optional if no auth |
+| Password | `SMTP_PASSWORD` | `smtp_password` | Sensitive, not shown when set |
+| Password | `SMTP_PASSWORD_FILE` | — | Docker/Secrets: path to file with password |
+| TLS/SSL | `SMTP_SSL` or similar | `smtp_ssl` | e.g. `tls` / `ssl` / `none` (default: tls)|
+
+**Sender (unchanged):** `mail_from` stays separate (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL` in ENV; no DB fields for from-address).
+
+---
+
+## 5. Password from File
+
+Support **SMTP_PASSWORD_FILE** (path to file containing the password), same pattern as `OIDC_CLIENT_SECRET_FILE` and `TOKEN_SIGNING_SECRET_FILE` in `runtime.exs`. Read once at runtime when building mailer config; ENV `SMTP_PASSWORD` overrides file if both are set (or define explicit precedence and document it).
+
+---
+
+## 6. Behaviour When SMTP Is Not Configured
+
+- **Dev/Test:** Keep current adapters (`Swoosh.Adapters.Local`, `Swoosh.Adapters.Test`). No change.
+- **Production:** If neither ENV nor Settings provide SMTP (e.g. no host):
+ - Keep using the default adapter (e.g. Local) or a no-op adapter so the app does not crash.
+ - **Show a clear warning in the Settings UI** (SMTP section): e.g. "SMTP is not configured. Transactional emails (join confirmation, password reset, etc.) will not be delivered reliably." and optionally list consequences (no join confirmations, no password resets, etc.).
+ - Log a warning at startup or when sending is attempted if SMTP is not configured in prod.
+
+---
+
+## 7. Test Email (Settings UI)
+
+- **Location:** SMTP / E-Mail section in Global Settings (same page as OIDC, Vereinfacht).
+- **Elements:**
+ - Input: **recipient email address** (required for sending).
+ - Button: **"Send test email"** (or similar).
+- **Behaviour:** On click, send one simple transactional-style email to the given address (subject and body translatable via Gettext, e.g. "Mila – Test email" / "This is a test."). Use current SMTP config and `mail_from`.
+- **Feedback:** Show success message or error (e.g. connection refused, auth failed, invalid address). Reuse the same UI pattern as Vereinfacht "Test Integration" (result assign, small result component with success/error states).
+- **Permission:** Reuse existing Settings page authorization (admin); no extra check for the test-email action.
+
+---
+
+## 8. Implementation Hints
+
+- **Config module:** Extend `Mv.Config` with `smtp_*` helpers (e.g. `smtp_host/0`, `smtp_port/0`, …) using `env_or_setting/2` and, for password, ENV vs `SMTP_PASSWORD_FILE` vs Settings (sensitive).
+- **runtime.exs:** When SMTP is configured (e.g. host present), set `config :mv, Mv.Mailer, adapter: Swoosh.Adapters.SMTP, ...` with the merged options. Otherwise leave adapter as in base config (Local in dev, Test in test, and in prod either Local with warning or explicit "not configured" behaviour).
+- **Setting resource:** New attributes: `smtp_host`, `smtp_port`, `smtp_username`, `smtp_password` (sensitive), `smtp_ssl` (string or enum). Add to create/update `accept` lists and to seeds if needed.
+- **Migration:** Add columns for the new Setting attributes.
+- **Test email:** New function (e.g. `Mv.Mailer.send_test_email(to_email)`) returning `{:ok, _}` or `{:error, reason}`; call from LiveView event and render result in the SMTP section.
+
+---
+
+## 9. Documentation and i18n
+
+- **Gettext:** Use Gettext for test email subject and body and for all new Settings labels/hints (including the "SMTP not configured" warning).
+- **Docs:** Update `CODE_GUIDELINES.md` (e.g. §3.11 Email) and deployment/configuration docs to describe ENV and Settings for SMTP and the test email. Add this feature to `docs/feature-roadmap.md` (e.g. under Admin Panel & Configuration or Communication).
+
+---
+
+## 10. Summary Checklist
+
+- [ ] ENV: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL` (or equivalent).
+- [ ] Settings: attributes and UI for host, port, username, password, TLS/SSL; ENV-override hints.
+- [ ] Password from file: `SMTP_PASSWORD_FILE` supported in runtime config.
+- [ ] Mailer: Swoosh SMTP adapter configured from merged ENV + Settings when SMTP is configured.
+- [ ] Prod warning: clear message in Settings when SMTP is not configured, with consequences.
+- [ ] Test email: button + recipient field, translatable content, success/error display; existing permission sufficient.
+- [ ] Gettext for new UI and test email text.
+- [ ] Feature roadmap and code guidelines updated.
diff --git a/lib/mv/config.ex b/lib/mv/config.ex
index 8b8c088..e176b8c 100644
--- a/lib/mv/config.ex
+++ b/lib/mv/config.ex
@@ -449,4 +449,42 @@ defmodule Mv.Config do
def oidc_admin_group_name_env_set?, do: env_set?("OIDC_ADMIN_GROUP_NAME")
def oidc_groups_claim_env_set?, do: env_set?("OIDC_GROUPS_CLAIM")
def oidc_only_env_set?, do: env_set?("OIDC_ONLY")
+
+ # ---------------------------------------------------------------------------
+ # SMTP configuration (stubs for TDD – ENV overrides Settings; see docs/smtp-configuration-concept.md)
+ # ---------------------------------------------------------------------------
+
+ @doc "Returns SMTP host. ENV SMTP_HOST overrides Settings. Stub: always nil until implemented."
+ @spec smtp_host() :: String.t() | nil
+ def smtp_host, do: nil
+
+ @doc "Returns SMTP port (e.g. 587). ENV SMTP_PORT overrides Settings. Stub: always nil until implemented."
+ @spec smtp_port() :: non_neg_integer() | nil
+ def smtp_port, do: nil
+
+ @doc "Returns SMTP username. ENV SMTP_USERNAME overrides Settings. Stub: always nil until implemented."
+ @spec smtp_username() :: String.t() | nil
+ def smtp_username, do: nil
+
+ @doc "Returns SMTP password. ENV SMTP_PASSWORD overrides SMTP_PASSWORD_FILE overrides Settings. Stub: always nil until implemented."
+ @spec smtp_password() :: String.t() | nil
+ def smtp_password, do: nil
+
+ @doc "Returns SMTP TLS/SSL mode (e.g. 'tls', 'ssl', 'none'). Stub: always nil until implemented."
+ @spec smtp_ssl() :: String.t() | nil
+ def smtp_ssl, do: nil
+
+ @doc "Returns true when SMTP is configured (e.g. host present). Stub: always false until implemented."
+ @spec smtp_configured?() :: boolean()
+ def smtp_configured?, do: false
+
+ @doc "Returns true when any SMTP ENV variable is set (for Settings UI hint). Stub: always false until implemented."
+ @spec smtp_env_configured?() :: boolean()
+ def smtp_env_configured?, do: false
+
+ def smtp_host_env_set?, do: env_set?("SMTP_HOST")
+ def smtp_port_env_set?, do: env_set?("SMTP_PORT")
+ def smtp_username_env_set?, do: env_set?("SMTP_USERNAME")
+ def smtp_password_env_set?, do: env_set?("SMTP_PASSWORD") or env_set?("SMTP_PASSWORD_FILE")
+ def smtp_ssl_env_set?, do: env_set?("SMTP_SSL")
end
diff --git a/lib/mv/mailer.ex b/lib/mv/mailer.ex
index 3d83636..e78735b 100644
--- a/lib/mv/mailer.ex
+++ b/lib/mv/mailer.ex
@@ -16,4 +16,15 @@ defmodule Mv.Mailer do
def mail_from do
Application.get_env(:mv, :mail_from, {"Mila", "noreply@example.com"})
end
+
+ @doc """
+ Sends a test email to the given address. Used from Global Settings SMTP section.
+
+ Returns `{:ok, email}` on success, `{:error, reason}` on failure (e.g. invalid address,
+ SMTP not configured, connection error). Stub: always returns error until implemented.
+ """
+ @spec send_test_email(String.t()) :: {:ok, Swoosh.Email.t()} | {:error, term()}
+ def send_test_email(_to_email) do
+ {:error, :not_implemented}
+ end
end
diff --git a/test/membership/setting_smtp_test.exs b/test/membership/setting_smtp_test.exs
new file mode 100644
index 0000000..ea4a954
--- /dev/null
+++ b/test/membership/setting_smtp_test.exs
@@ -0,0 +1,63 @@
+defmodule Mv.Membership.SettingSmtpTest do
+ @moduledoc """
+ Unit tests for Setting resource SMTP attributes.
+
+ TDD: tests expect smtp_host, smtp_port, smtp_username, smtp_password, smtp_ssl
+ to be accepted on update and persisted. Password must not be exposed in plaintext
+ when reading settings (sensitive). Tests will fail until Setting has these attributes.
+ """
+ use Mv.DataCase, async: false
+
+ alias Mv.Helpers.SystemActor
+ alias Mv.Membership
+
+ setup do
+ {:ok, settings} = Membership.get_settings()
+ # Save current SMTP values to restore in on_exit (when attributes exist)
+ saved = %{
+ smtp_host: Map.get(settings, :smtp_host),
+ smtp_port: Map.get(settings, :smtp_port),
+ smtp_username: Map.get(settings, :smtp_username),
+ smtp_ssl: Map.get(settings, :smtp_ssl)
+ }
+
+ on_exit(fn ->
+ {:ok, s} = Membership.get_settings()
+ attrs = Enum.reject(saved, fn {_k, v} -> is_nil(v) end) |> Map.new()
+ if attrs != %{}, do: Membership.update_settings(s, attrs)
+ end)
+
+ {:ok, settings: settings, saved: saved}
+ end
+
+ describe "SMTP attributes update and persistence" do
+ test "update_settings accepts smtp_host, smtp_port, smtp_username, smtp_ssl and persists", %{
+ settings: settings
+ } do
+ attrs = %{
+ smtp_host: "smtp.example.com",
+ smtp_port: 587,
+ smtp_username: "user",
+ smtp_ssl: "tls"
+ }
+
+ assert {:ok, updated} = Membership.update_settings(settings, attrs)
+ assert updated.smtp_host == "smtp.example.com"
+ assert updated.smtp_port == 587
+ assert updated.smtp_username == "user"
+ assert updated.smtp_ssl == "tls"
+ end
+
+ test "smtp_password can be set and is not exposed in plaintext when reading settings", %{
+ settings: settings
+ } do
+ secret = "sensitive-password-#{System.unique_integer([:positive])}"
+ assert {:ok, _} = Membership.update_settings(settings, %{smtp_password: secret})
+
+ {:ok, read_back} = Membership.get_settings()
+ # Sensitive: raw password must not be returned (e.g. nil or redacted)
+ refute read_back.smtp_password == secret,
+ "smtp_password must not be returned in plaintext when reading settings"
+ end
+ end
+end
diff --git a/test/mv/config_smtp_test.exs b/test/mv/config_smtp_test.exs
new file mode 100644
index 0000000..5359366
--- /dev/null
+++ b/test/mv/config_smtp_test.exs
@@ -0,0 +1,129 @@
+defmodule Mv.ConfigSmtpTest do
+ @moduledoc """
+ Unit tests for Mv.Config SMTP-related helpers.
+
+ ENV overrides Settings (same pattern as OIDC/Vereinfacht). Uses real ENV and
+ Settings; no mocking so we test the actual precedence. async: false because
+ we mutate ENV.
+ """
+ use Mv.DataCase, async: false
+
+ describe "smtp_host/0" do
+ test "returns ENV value when SMTP_HOST is set" do
+ set_smtp_env("SMTP_HOST", "smtp.example.com")
+ assert Mv.Config.smtp_host() == "smtp.example.com"
+ after
+ clear_smtp_env()
+ end
+
+ test "returns nil when SMTP_HOST is not set and Settings have no smtp_host" do
+ clear_smtp_env()
+ assert Mv.Config.smtp_host() == nil
+ end
+ end
+
+ describe "smtp_port/0" do
+ test "returns parsed integer when SMTP_PORT ENV is set" do
+ set_smtp_env("SMTP_PORT", "587")
+ assert Mv.Config.smtp_port() == 587
+ after
+ clear_smtp_env()
+ end
+
+ test "returns nil or default when SMTP_PORT is not set" do
+ clear_smtp_env()
+ port = Mv.Config.smtp_port()
+ assert port == nil or (is_integer(port) and port in [25, 465, 587])
+ end
+ end
+
+ describe "smtp_configured?/0" do
+ test "returns true when smtp_host is present (from ENV or Settings)" do
+ set_smtp_env("SMTP_HOST", "smtp.example.com")
+ assert Mv.Config.smtp_configured?() == true
+ after
+ clear_smtp_env()
+ end
+
+ test "returns false when no SMTP host is set" do
+ clear_smtp_env()
+ refute Mv.Config.smtp_configured?()
+ end
+ end
+
+ describe "smtp_env_configured?/0" do
+ test "returns true when any SMTP ENV variable is set" do
+ set_smtp_env("SMTP_HOST", "smtp.example.com")
+ assert Mv.Config.smtp_env_configured?() == true
+ after
+ clear_smtp_env()
+ end
+
+ test "returns false when no SMTP ENV variables are set" do
+ clear_smtp_env()
+ refute Mv.Config.smtp_env_configured?()
+ end
+ end
+
+ describe "smtp_password/0 and SMTP_PASSWORD_FILE" do
+ test "returns value from SMTP_PASSWORD when set" do
+ set_smtp_env("SMTP_PASSWORD", "env-secret")
+ assert Mv.Config.smtp_password() == "env-secret"
+ after
+ clear_smtp_env()
+ end
+
+ test "returns content of file when SMTP_PASSWORD_FILE is set and SMTP_PASSWORD is not" do
+ clear_smtp_env()
+ path = Path.join(System.tmp_dir!(), "mv_smtp_test_#{System.unique_integer([:positive])}")
+ File.write!(path, "file-secret\n")
+ Process.put(:smtp_password_file_path, path)
+ set_smtp_env("SMTP_PASSWORD_FILE", path)
+ assert Mv.Config.smtp_password() == "file-secret"
+ after
+ clear_smtp_env()
+ if path = Process.get(:smtp_password_file_path), do: File.rm(path)
+ end
+
+ test "SMTP_PASSWORD overrides SMTP_PASSWORD_FILE when both are set" do
+ path = Path.join(System.tmp_dir!(), "mv_smtp_test_#{System.unique_integer([:positive])}")
+ File.write!(path, "file-secret")
+ Process.put(:smtp_password_file_path, path)
+ set_smtp_env("SMTP_PASSWORD_FILE", path)
+ set_smtp_env("SMTP_PASSWORD", "env-wins")
+ assert Mv.Config.smtp_password() == "env-wins"
+ after
+ clear_smtp_env()
+ if path = Process.get(:smtp_password_file_path), do: File.rm(path)
+ end
+ end
+
+ describe "smtp_*_env_set?/0" do
+ test "smtp_host_env_set? returns true when SMTP_HOST is set" do
+ set_smtp_env("SMTP_HOST", "x")
+ assert Mv.Config.smtp_host_env_set?() == true
+ after
+ clear_smtp_env()
+ end
+
+ test "smtp_password_env_set? returns true when SMTP_PASSWORD or SMTP_PASSWORD_FILE is set" do
+ set_smtp_env("SMTP_PASSWORD", "x")
+ assert Mv.Config.smtp_password_env_set?() == true
+ after
+ clear_smtp_env()
+ end
+ end
+
+ defp set_smtp_env(key, value) do
+ System.put_env(key, value)
+ end
+
+ defp clear_smtp_env do
+ System.delete_env("SMTP_HOST")
+ System.delete_env("SMTP_PORT")
+ System.delete_env("SMTP_USERNAME")
+ System.delete_env("SMTP_PASSWORD")
+ System.delete_env("SMTP_PASSWORD_FILE")
+ System.delete_env("SMTP_SSL")
+ end
+end
diff --git a/test/mv/mailer_test.exs b/test/mv/mailer_test.exs
new file mode 100644
index 0000000..22cc49f
--- /dev/null
+++ b/test/mv/mailer_test.exs
@@ -0,0 +1,46 @@
+defmodule Mv.MailerTest do
+ @moduledoc """
+ Unit tests for Mv.Mailer, in particular send_test_email/1.
+
+ Uses Swoosh.Adapters.Test (configured in test.exs); no real SMTP. Asserts
+ success/error contract and that one test email is sent on success.
+ """
+ use Mv.DataCase, async: true
+
+ import Swoosh.TestAssertions
+
+ alias Mv.Mailer
+
+ describe "send_test_email/1" do
+ test "returns {:ok, email} and sends one email with expected subject/body when successful" do
+ to_email = "test-#{System.unique_integer([:positive])}@example.com"
+
+ assert {:ok, _email} = Mailer.send_test_email(to_email)
+
+ assert_email_sent(fn email ->
+ to_addresses = Enum.map(email.to, &elem(&1, 1))
+ subject = email.subject || ""
+ body = email.html_body || email.text_body || ""
+
+ to_email in to_addresses and
+ (String.contains?(subject, "Test") or String.contains?(body, "test"))
+ end)
+ end
+
+ test "returns {:error, reason} for invalid email address" do
+ result = Mailer.send_test_email("not-an-email")
+ assert {:error, _reason} = result
+ end
+
+ test "uses mail_from as sender" do
+ to_email = "recipient-#{System.unique_integer([:positive])}@example.com"
+ assert {:ok, _} = Mailer.send_test_email(to_email)
+
+ assert_email_sent(fn email ->
+ {_name, from_email} = Mailer.mail_from()
+ from_addresses = Enum.map(email.from, &elem(&1, 1))
+ from_email in from_addresses
+ end)
+ end
+ end
+end
diff --git a/test/mv_web/live/global_settings_live_test.exs b/test/mv_web/live/global_settings_live_test.exs
index 6a739b5..0cb4ead 100644
--- a/test/mv_web/live/global_settings_live_test.exs
+++ b/test/mv_web/live/global_settings_live_test.exs
@@ -65,4 +65,52 @@ defmodule MvWeb.GlobalSettingsLiveTest do
assert html =~ "must be present"
end
end
+
+ describe "SMTP / E-Mail section" do
+ setup %{conn: conn} do
+ user = create_test_user(%{email: "admin@example.com"})
+ conn = conn_with_oidc_user(conn, user)
+ {:ok, conn: conn, user: user}
+ end
+
+ test "renders SMTP section with host/port fields and test email area", %{conn: conn} do
+ {:ok, _view, html} = live(conn, ~p"/settings")
+ # Section title (Gettext key: SMTP or E-Mail per concept)
+ assert html =~ "SMTP" or html =~ "E-Mail"
+ end
+
+ test "shows Send test email button when SMTP is configured", %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/settings")
+ # When Mv.Config.smtp_configured?() is true, button and recipient input should be present
+ # In test env SMTP is typically not configured; we only assert the section exists
+ html = render(view)
+ assert html =~ "SMTP" or html =~ "E-Mail"
+ end
+
+ test "send test email with valid address shows success or error result", %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/settings")
+ # If test email UI exists: fill recipient, click button, assert result area updates
+ # Uses data-testid or button text "Send test email" / "Test email"
+ if has_element?(view, "[data-testid='smtp-test-email-form']") do
+ view
+ |> element("[data-testid='smtp-test-email-input']")
+ |> render_change(%{"to_email" => "test@example.com"})
+ view
+ |> element("[data-testid='smtp-send-test-email']")
+ |> render_click()
+ # Result is either success or error message
+ assert has_element?(view, "[data-testid='smtp-test-result']")
+ else
+ # Section not yet implemented: just ensure page still renders
+ assert render(view) =~ "Settings"
+ end
+ end
+
+ test "shows warning when SMTP is not configured in production", %{conn: conn} do
+ # Concept: in prod, show warning "SMTP is not configured. Transactional emails..."
+ # In test we only check that the section exists; warning visibility is env-dependent
+ {:ok, view, html} = live(conn, ~p"/settings")
+ assert html =~ "SMTP" or html =~ "E-Mail" or html =~ "Settings"
+ end
+ end
end
diff --git a/test/mv_web/live/join_live_test.exs b/test/mv_web/live/join_live_test.exs
index bd133cd..1458973 100644
--- a/test/mv_web/live/join_live_test.exs
+++ b/test/mv_web/live/join_live_test.exs
@@ -39,6 +39,8 @@ defmodule MvWeb.JoinLiveTest do
test "submit with valid allowlist data creates one JoinRequest and shows success copy", %{
conn: conn
} do
+ # Re-apply allowlist so this test is robust when run in parallel with others (Settings singleton).
+ enable_join_form_for_test(%{})
count_before = count_join_requests()
{:ok, view, _html} = live(conn, "/join")
From a4f3aa5d6ff6ee903f1a2157d7f1941045b09b52 Mon Sep 17 00:00:00 2001
From: Simon
Date: Thu, 12 Mar 2026 13:39:48 +0100
Subject: [PATCH 2/4] feat: add smtp settings
---
CODE_GUIDELINES.md | 28 +-
config/runtime.exs | 61 ++-
docs/feature-roadmap.md | 6 +-
docs/smtp-configuration-concept.md | 99 +++--
lib/membership/setting.ex | 107 ++++-
.../send_new_user_confirmation_email.ex | 31 +-
.../user/senders/send_password_reset_email.ex | 28 +-
lib/mv/config.ex | 181 ++++++++-
lib/mv/mailer.ex | 187 ++++++++-
lib/mv_web/live/global_settings_live.ex | 381 +++++++++++++++++-
mix.exs | 2 +
mix.lock | 1 +
priv/gettext/de/LC_MESSAGES/default.po | 186 ++++++++-
priv/gettext/default.pot | 186 ++++++++-
priv/gettext/en/LC_MESSAGES/default.po | 186 ++++++++-
.../20260311082352_add_smtp_to_settings.exs | 27 ++
...260311140000_add_mail_from_to_settings.exs | 18 +
.../repo/join_requests/20260311082353.json | 243 +++++++++++
.../repo/members/20260311082354.json | 246 +++++++++++
.../repo/settings/20260311082355.json | 347 ++++++++++++++++
test/membership/setting_smtp_test.exs | 1 -
test/mv/mailer_test.exs | 7 +-
.../mv_web/live/global_settings_live_test.exs | 17 +-
23 files changed, 2424 insertions(+), 152 deletions(-)
create mode 100644 priv/repo/migrations/20260311082352_add_smtp_to_settings.exs
create mode 100644 priv/repo/migrations/20260311140000_add_mail_from_to_settings.exs
create mode 100644 priv/resource_snapshots/repo/join_requests/20260311082353.json
create mode 100644 priv/resource_snapshots/repo/members/20260311082354.json
create mode 100644 priv/resource_snapshots/repo/settings/20260311082355.json
diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md
index 6f8deb5..e1dfc75 100644
--- a/CODE_GUIDELINES.md
+++ b/CODE_GUIDELINES.md
@@ -1267,7 +1267,27 @@ mix hex.outdated
**Mailer and from address:**
- `Mv.Mailer` (Swoosh) and `Mv.Mailer.mail_from/0` return the configured sender `{name, email}`.
-- Config: `config :mv, :mail_from, {"Mila", "noreply@example.com"}` in config.exs. In production, runtime.exs overrides from ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`).
+- Sender identity priority: `MAIL_FROM_NAME`/`MAIL_FROM_EMAIL` ENV > Settings `smtp_from_name`/`smtp_from_email` > hardcoded defaults `{"Mila", "noreply@example.com"}`.
+- Access via `Mv.Config.mail_from_name/0` and `Mv.Config.mail_from_email/0`.
+- **Important:** On most SMTP servers the sender email must be the same address as `smtp_username` or an alias owned by that account (e.g. Postfix strict relay). Misconfiguration causes a 553 error.
+
+**SMTP configuration:**
+
+- SMTP can be configured via **ENV variables** (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) or via **Admin Settings** (database: `smtp_host`, `smtp_port`, `smtp_username`, `smtp_password`, `smtp_ssl`). ENV takes priority (same pattern as OIDC/Vereinfacht).
+- Sender identity is also configurable via ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`) or Settings (`smtp_from_name`, `smtp_from_email`).
+- `SMTP_PASSWORD_FILE`: path to a file containing the password (Docker Secrets / Kubernetes secrets pattern); overridden by `SMTP_PASSWORD` when both are set.
+- `SMTP_SSL` values: `tls` (default, port 587), `ssl` (port 465), `none` (port 25).
+- When `SMTP_HOST` ENV is present at boot, `runtime.exs` configures `Swoosh.Adapters.SMTP` automatically.
+- When SMTP is configured only via Settings, `Mv.Mailer.smtp_config/0` builds the adapter config per-send.
+- In test environment, `Swoosh.Adapters.Test` is used regardless of SMTP config.
+- **TLS in OTP 27:** `tls_options: [verify: :verify_none]` (STARTTLS/587) and `sockopts: [verify: :verify_none]` (SSL/465) are set to allow self-signed / internal certs.
+- **Test email:** `Mv.Mailer.send_test_email(to_email)` sends a transactional test email; returns `{:ok, email}` or `{:error, classified_reason}`. Classified errors: `:sender_rejected`, `:auth_failed`, `:recipient_rejected`, `:tls_failed`, `:connection_failed`, `{:smtp_error, message}`. Each shows a specific message in the UI.
+- **Production warning:** When SMTP is not configured in production, a warning is shown in the Settings UI.
+- Access config values via `Mv.Config.smtp_host/0`, `smtp_port/0`, `smtp_username/0`, `smtp_password/0`, `smtp_ssl/0`, `smtp_configured?/0`.
+
+**AshAuthentication senders:**
+
+- `SendPasswordResetEmail` and `SendNewUserConfirmationEmail` use `Mv.Mailer.deliver/1` (not `deliver!/1`). Errors are logged via `Logger.error` and not re-raised so they never crash the caller process.
**Unified layout (transactional emails):**
@@ -1287,7 +1307,11 @@ new()
|> put_view(MvWeb.EmailsView)
|> put_layout({MvWeb.EmailLayoutView, "layout.html"})
|> render_body("template_name.html", %{assigns})
-|> Mailer.deliver!()
+
+case Mailer.deliver(email) do
+ {:ok, _} -> :ok
+ {:error, reason} -> Logger.error("Email delivery failed: #{inspect(reason)}")
+end
```
### 3.12 Internationalization: Gettext
diff --git a/config/runtime.exs b/config/runtime.exs
index b8570d8..b522426 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -223,19 +223,50 @@ if config_env() == :prod do
{System.get_env("MAIL_FROM_NAME", "Mila"),
System.get_env("MAIL_FROM_EMAIL", "noreply@example.com")}
- # In production you may need to configure the mailer to use a different adapter.
- # Also, you may need to configure the Swoosh API client of your choice if you
- # are not using SMTP. Here is an example of the configuration:
- #
- # config :mv, Mv.Mailer,
- # adapter: Swoosh.Adapters.Mailgun,
- # api_key: System.get_env("MAILGUN_API_KEY"),
- # domain: System.get_env("MAILGUN_DOMAIN")
- #
- # For this example you need include a HTTP client required by Swoosh API client.
- # Swoosh supports Hackney, Req and Finch out of the box:
- #
- # config :swoosh, :api_client, Swoosh.ApiClient.Hackney
- #
- # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
+ # SMTP configuration from environment variables (overrides base adapter in prod).
+ # When SMTP_HOST is set, configure Swoosh to use the SMTP adapter at boot time.
+ # If SMTP is configured only via Settings (Admin UI), the mailer builds the config
+ # per-send at runtime using Mv.Config.smtp_*() helpers.
+ smtp_host_env = System.get_env("SMTP_HOST")
+
+ if smtp_host_env && String.trim(smtp_host_env) != "" do
+ smtp_port_env =
+ case System.get_env("SMTP_PORT") do
+ nil -> 587
+ v -> String.to_integer(String.trim(v))
+ end
+
+ smtp_password_env =
+ case System.get_env("SMTP_PASSWORD") do
+ nil ->
+ case System.get_env("SMTP_PASSWORD_FILE") do
+ nil -> nil
+ path -> path |> File.read!() |> String.trim()
+ end
+
+ v ->
+ v
+ end
+
+ smtp_ssl_mode = System.get_env("SMTP_SSL", "tls")
+
+ smtp_opts =
+ [
+ adapter: Swoosh.Adapters.SMTP,
+ relay: String.trim(smtp_host_env),
+ port: smtp_port_env,
+ username: System.get_env("SMTP_USERNAME"),
+ password: smtp_password_env,
+ ssl: smtp_ssl_mode == "ssl",
+ tls: if(smtp_ssl_mode == "tls", do: :always, else: :never),
+ auth: :always,
+ # Allow self-signed or internal SMTP server certs (OTP 26+ enforces verify_peer with cacerts).
+ # tls_options: STARTTLS (587); sockopts: direct SSL (465).
+ tls_options: [verify: :verify_none],
+ sockopts: [verify: :verify_none]
+ ]
+ |> Enum.reject(fn {_k, v} -> is_nil(v) end)
+
+ config :mv, Mv.Mailer, smtp_opts
+ end
end
diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md
index f3b1e27..c74f064 100644
--- a/docs/feature-roadmap.md
+++ b/docs/feature-roadmap.md
@@ -270,8 +270,10 @@
**Open Issues:**
- [#186](https://git.local-it.org/local-it/mitgliederverwaltung/issues/186) - Create Architecture docs in Repo (S, Low priority)
+**Implemented Features:**
+- ✅ **SMTP configuration** – Configure mail server via ENV (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) and Admin Settings (UI), with ENV taking priority. Test email from Settings SMTP section. Production warning when SMTP is not configured. See [`docs/smtp-configuration-concept.md`](smtp-configuration-concept.md).
+
**Missing Features:**
-- ❌ **SMTP configuration** – Configure mail server via ENV and Admin Settings, test email from Settings. See [`docs/smtp-configuration-concept.md`](smtp-configuration-concept.md).
- ❌ Email templates configuration
- ❌ System health dashboard
- ❌ Audit log viewer
@@ -288,7 +290,7 @@
- ✅ Swoosh mailer integration
- ✅ Email confirmation (via AshAuthentication)
- ✅ Password reset emails (via AshAuthentication)
-- ⚠️ No SMTP configuration (mailer uses Local/Test adapter; prod not configured)
+- ✅ **SMTP configuration** via ENV and Admin Settings (see Admin Panel section)
- ⚠️ No member communication features
**Missing Features:**
diff --git a/docs/smtp-configuration-concept.md b/docs/smtp-configuration-concept.md
index b0ca8cc..75e3e85 100644
--- a/docs/smtp-configuration-concept.md
+++ b/docs/smtp-configuration-concept.md
@@ -1,7 +1,7 @@
# SMTP Configuration – Concept
-**Status:** Draft
-**Last updated:** 2026-03-11
+**Status:** Implemented
+**Last updated:** 2026-03-12
---
@@ -13,8 +13,8 @@ Enable configurable SMTP for sending transactional emails (join confirmation, us
## 2. Scope
-- **In scope:** SMTP server configuration (host, port, credentials, TLS/SSL), test email from Settings UI, warning when SMTP is not configured in production.
-- **Out of scope:** Changing how AshAuthentication or existing senders use the mailer; they keep using `Mv.Mailer` and `mail_from/0`. No separate "form_mail" config – the existing **mail_from** (MAIL_FROM_NAME, MAIL_FROM_EMAIL) remains the single sender identity for all transactional mails.
+- **In scope:** SMTP server configuration (host, port, credentials, TLS/SSL), sender identity (from-name, from-email), test email from Settings UI, warning when SMTP is not configured in production, specific error messages per failure category, graceful delivery errors in AshAuthentication senders.
+- **Out of scope:** Separate adapters per email type; retry queues.
---
@@ -31,71 +31,84 @@ When an ENV variable is set, the corresponding Settings field is read-only in th
## 4. SMTP Parameters
-| Parameter | ENV | Settings attribute | Notes |
-|------------|------------------------|--------------------|--------------------------------------------|
-| Host | `SMTP_HOST` | `smtp_host` | e.g. `smtp.example.com` |
-| Port | `SMTP_PORT` | `smtp_port` | Default 587 (TLS), 465 (SSL), 25 (plain) |
-| Username | `SMTP_USERNAME` | `smtp_username` | Optional if no auth |
-| Password | `SMTP_PASSWORD` | `smtp_password` | Sensitive, not shown when set |
-| Password | `SMTP_PASSWORD_FILE` | — | Docker/Secrets: path to file with password |
-| TLS/SSL | `SMTP_SSL` or similar | `smtp_ssl` | e.g. `tls` / `ssl` / `none` (default: tls)|
+| Parameter | ENV | Settings attribute | Notes |
+|----------------|------------------------|---------------------|---------------------------------------------|
+| Host | `SMTP_HOST` | `smtp_host` | e.g. `smtp.example.com` |
+| Port | `SMTP_PORT` | `smtp_port` | Default 587 (TLS), 465 (SSL), 25 (plain) |
+| Username | `SMTP_USERNAME` | `smtp_username` | Optional if no auth |
+| Password | `SMTP_PASSWORD` | `smtp_password` | Sensitive, not shown when set |
+| Password file | `SMTP_PASSWORD_FILE` | — | Docker/Secrets: path to file with password |
+| TLS/SSL | `SMTP_SSL` | `smtp_ssl` | `tls` / `ssl` / `none` (default: tls) |
+| Sender name | `MAIL_FROM_NAME` | `smtp_from_name` | Display name in "From" header (default: Mila)|
+| Sender email | `MAIL_FROM_EMAIL` | `smtp_from_email` | Address in "From" header; must match SMTP user on most servers |
-**Sender (unchanged):** `mail_from` stays separate (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL` in ENV; no DB fields for from-address).
+**Important:** On most SMTP servers (e.g. Postfix with strict relay policies) the sender email (`smtp_from_email`) must be the same address as `smtp_username` or an alias that is owned by that account.
---
## 5. Password from File
-Support **SMTP_PASSWORD_FILE** (path to file containing the password), same pattern as `OIDC_CLIENT_SECRET_FILE` and `TOKEN_SIGNING_SECRET_FILE` in `runtime.exs`. Read once at runtime when building mailer config; ENV `SMTP_PASSWORD` overrides file if both are set (or define explicit precedence and document it).
+Support **SMTP_PASSWORD_FILE** (path to file containing the password), same pattern as `OIDC_CLIENT_SECRET_FILE` in `runtime.exs`. Read once at runtime; `SMTP_PASSWORD` ENV overrides file if both are set.
---
## 6. Behaviour When SMTP Is Not Configured
- **Dev/Test:** Keep current adapters (`Swoosh.Adapters.Local`, `Swoosh.Adapters.Test`). No change.
-- **Production:** If neither ENV nor Settings provide SMTP (e.g. no host):
- - Keep using the default adapter (e.g. Local) or a no-op adapter so the app does not crash.
- - **Show a clear warning in the Settings UI** (SMTP section): e.g. "SMTP is not configured. Transactional emails (join confirmation, password reset, etc.) will not be delivered reliably." and optionally list consequences (no join confirmations, no password resets, etc.).
- - Log a warning at startup or when sending is attempted if SMTP is not configured in prod.
+- **Production:** If neither ENV nor Settings provide SMTP (no host):
+ - Show a warning in the Settings UI.
+ - Delivery attempts silently fall back to the Local adapter (no crash).
---
## 7. Test Email (Settings UI)
-- **Location:** SMTP / E-Mail section in Global Settings (same page as OIDC, Vereinfacht).
-- **Elements:**
- - Input: **recipient email address** (required for sending).
- - Button: **"Send test email"** (or similar).
-- **Behaviour:** On click, send one simple transactional-style email to the given address (subject and body translatable via Gettext, e.g. "Mila – Test email" / "This is a test."). Use current SMTP config and `mail_from`.
-- **Feedback:** Show success message or error (e.g. connection refused, auth failed, invalid address). Reuse the same UI pattern as Vereinfacht "Test Integration" (result assign, small result component with success/error states).
-- **Permission:** Reuse existing Settings page authorization (admin); no extra check for the test-email action.
+- **Location:** SMTP / E-Mail section in Global Settings.
+- **Elements:** Input for recipient, submit button inside a `phx-submit` form.
+- **Behaviour:** Sends one email using current SMTP config and `mail_from/0`. Returns `{:ok, _}` or `{:error, classified_reason}`.
+- **Error categories:** `:sender_rejected`, `:auth_failed`, `:recipient_rejected`, `:tls_failed`, `:connection_failed`, `{:smtp_error, message}` — each shows a specific human-readable message in the UI.
+- **Permission:** Reuses existing Settings page authorization (admin).
---
-## 8. Implementation Hints
+## 8. Sender Identity (`mail_from`)
-- **Config module:** Extend `Mv.Config` with `smtp_*` helpers (e.g. `smtp_host/0`, `smtp_port/0`, …) using `env_or_setting/2` and, for password, ENV vs `SMTP_PASSWORD_FILE` vs Settings (sensitive).
-- **runtime.exs:** When SMTP is configured (e.g. host present), set `config :mv, Mv.Mailer, adapter: Swoosh.Adapters.SMTP, ...` with the merged options. Otherwise leave adapter as in base config (Local in dev, Test in test, and in prod either Local with warning or explicit "not configured" behaviour).
-- **Setting resource:** New attributes: `smtp_host`, `smtp_port`, `smtp_username`, `smtp_password` (sensitive), `smtp_ssl` (string or enum). Add to create/update `accept` lists and to seeds if needed.
-- **Migration:** Add columns for the new Setting attributes.
-- **Test email:** New function (e.g. `Mv.Mailer.send_test_email(to_email)`) returning `{:ok, _}` or `{:error, reason}`; call from LiveView event and render result in the SMTP section.
+`Mv.Mailer.mail_from/0` returns `{name, email}`. Priority:
+1. `MAIL_FROM_NAME` / `MAIL_FROM_EMAIL` ENV variables
+2. `smtp_from_name` / `smtp_from_email` in Settings (DB)
+3. Hardcoded defaults: `{"Mila", "noreply@example.com"}`
+
+Provided by `Mv.Config.mail_from_name/0` and `Mv.Config.mail_from_email/0`.
---
-## 9. Documentation and i18n
+## 9. AshAuthentication Senders
-- **Gettext:** Use Gettext for test email subject and body and for all new Settings labels/hints (including the "SMTP not configured" warning).
-- **Docs:** Update `CODE_GUIDELINES.md` (e.g. §3.11 Email) and deployment/configuration docs to describe ENV and Settings for SMTP and the test email. Add this feature to `docs/feature-roadmap.md` (e.g. under Admin Panel & Configuration or Communication).
+Both `SendPasswordResetEmail` and `SendNewUserConfirmationEmail` use `Mv.Mailer.deliver/1` (not `deliver!/1`). Delivery failures are logged (`Logger.error`) and not re-raised, so they never crash the caller process. AshAuthentication ignores the return value of `send/3`.
---
-## 10. Summary Checklist
+## 10. TLS / SSL in OTP 27
-- [ ] ENV: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL` (or equivalent).
-- [ ] Settings: attributes and UI for host, port, username, password, TLS/SSL; ENV-override hints.
-- [ ] Password from file: `SMTP_PASSWORD_FILE` supported in runtime config.
-- [ ] Mailer: Swoosh SMTP adapter configured from merged ENV + Settings when SMTP is configured.
-- [ ] Prod warning: clear message in Settings when SMTP is not configured, with consequences.
-- [ ] Test email: button + recipient field, translatable content, success/error display; existing permission sufficient.
-- [ ] Gettext for new UI and test email text.
-- [ ] Feature roadmap and code guidelines updated.
+OTP 26+ enforces `verify_peer` by default, which fails for self-signed or internal SMTP server certificates.
+
+Both `tls_options: [verify: :verify_none]` (for STARTTLS, port 587) and `sockopts: [verify: :verify_none]` (for direct SSL, port 465) are set in `Mv.Mailer.smtp_config/0` to allow such certificates.
+
+For ENV-based boot config, the same options are set in `config/runtime.exs`.
+
+---
+
+## 11. Summary Checklist
+
+- [x] ENV: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`.
+- [x] ENV: `MAIL_FROM_NAME`, `MAIL_FROM_EMAIL` for sender identity.
+- [x] Settings: attributes and UI for host, port, username, password, TLS/SSL, from-name, from-email.
+- [x] Password from file: `SMTP_PASSWORD_FILE` supported in `runtime.exs`.
+- [x] Mailer: Swoosh SMTP adapter configured from merged ENV + Settings when SMTP is configured.
+- [x] Per-request SMTP config via `Mv.Mailer.smtp_config/0` for Settings-only scenarios.
+- [x] TLS certificate validation relaxed for OTP 27 (tls_options + sockopts).
+- [x] Prod warning: clear message in Settings when SMTP is not configured.
+- [x] Test email: form with recipient field, translatable content, classified success/error messages.
+- [x] AshAuthentication senders: graceful error handling (no crash on delivery failure).
+- [x] Gettext for all new UI strings, translated to German.
+- [x] Docs and code guidelines updated.
diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex
index bc2b1e7..827e194 100644
--- a/lib/membership/setting.ex
+++ b/lib/membership/setting.ex
@@ -58,7 +58,8 @@ defmodule Mv.Membership.Setting do
"""
use Ash.Resource,
domain: Mv.Membership,
- data_layer: AshPostgres.DataLayer
+ data_layer: AshPostgres.DataLayer,
+ primary_read_warning?: false
# Used in join_form_field_ids validation (compile-time to avoid recompiling regex and list on every validation)
@uuid_pattern ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@@ -73,8 +74,50 @@ defmodule Mv.Membership.Setting do
description "Global application settings (singleton resource)"
end
+ # All public attributes except smtp_password, used to exclude it from default reads.
+ # This list is used in the read prepare to prevent the sensitive password from being
+ # returned in standard reads (it can still be read via explicit select in Config).
+ @public_attributes [
+ :id,
+ :club_name,
+ :member_field_visibility,
+ :member_field_required,
+ :include_joining_cycle,
+ :default_membership_fee_type_id,
+ :vereinfacht_api_url,
+ :vereinfacht_api_key,
+ :vereinfacht_club_id,
+ :vereinfacht_app_url,
+ :oidc_client_id,
+ :oidc_base_url,
+ :oidc_redirect_uri,
+ :oidc_client_secret,
+ :oidc_admin_group_name,
+ :oidc_groups_claim,
+ :oidc_only,
+ :smtp_host,
+ :smtp_port,
+ :smtp_username,
+ :smtp_ssl,
+ :smtp_from_name,
+ :smtp_from_email,
+ :join_form_enabled,
+ :join_form_field_ids,
+ :join_form_field_required,
+ :inserted_at,
+ :updated_at
+ ]
+
actions do
- defaults [:read]
+ read :read do
+ primary? true
+
+ # smtp_password is excluded from the default select to prevent it from being returned
+ # in plaintext via standard reads. Config reads it via an explicit select internally.
+ prepare fn query, _context ->
+ Ash.Query.select(query, @public_attributes)
+ end
+ end
# Internal create action - not exposed via code interface
# Used only as fallback in get_settings/0 if settings don't exist
@@ -97,6 +140,13 @@ defmodule Mv.Membership.Setting do
:oidc_admin_group_name,
:oidc_groups_claim,
:oidc_only,
+ :smtp_host,
+ :smtp_port,
+ :smtp_username,
+ :smtp_password,
+ :smtp_ssl,
+ :smtp_from_name,
+ :smtp_from_email,
:join_form_enabled,
:join_form_field_ids,
:join_form_field_required
@@ -126,6 +176,13 @@ defmodule Mv.Membership.Setting do
:oidc_admin_group_name,
:oidc_groups_claim,
:oidc_only,
+ :smtp_host,
+ :smtp_port,
+ :smtp_username,
+ :smtp_password,
+ :smtp_ssl,
+ :smtp_from_name,
+ :smtp_from_email,
:join_form_enabled,
:join_form_field_ids,
:join_form_field_required
@@ -429,6 +486,52 @@ defmodule Mv.Membership.Setting do
description "When true and OIDC is configured, sign-in shows only OIDC (password login hidden)"
end
+ # SMTP configuration (can be overridden by ENV)
+ attribute :smtp_host, :string do
+ allow_nil? true
+ public? true
+ description "SMTP server hostname (e.g. smtp.example.com)"
+ end
+
+ attribute :smtp_port, :integer do
+ allow_nil? true
+ public? true
+ description "SMTP server port (e.g. 587 for TLS, 465 for SSL, 25 for plain)"
+ end
+
+ attribute :smtp_username, :string do
+ allow_nil? true
+ public? true
+ description "SMTP authentication username"
+ end
+
+ attribute :smtp_password, :string do
+ allow_nil? true
+ public? false
+ description "SMTP authentication password (sensitive)"
+ sensitive? true
+ end
+
+ attribute :smtp_ssl, :string do
+ allow_nil? true
+ public? true
+ description "SMTP TLS/SSL mode: 'tls', 'ssl', or 'none'"
+ end
+
+ attribute :smtp_from_name, :string do
+ allow_nil? true
+ public? true
+
+ description "Display name for the transactional email sender (e.g. 'Mila'). Overrides MAIL_FROM_NAME env."
+ end
+
+ attribute :smtp_from_email, :string do
+ allow_nil? true
+ public? true
+
+ description "Email address for the transactional email sender. Must be owned by the SMTP user. Overrides MAIL_FROM_EMAIL env."
+ end
+
# Join form (Beitrittsformular) settings
attribute :join_form_enabled, :boolean do
allow_nil? false
diff --git a/lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex b/lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex
index 393a220..7312b91 100644
--- a/lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex
+++ b/lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex
@@ -16,6 +16,8 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
import Swoosh.Email
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
+ require Logger
+
alias Mv.Mailer
@doc """
@@ -30,7 +32,8 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
- `_opts` - Additional options (unused)
## Returns
- The Swoosh.Email delivery result from `Mailer.deliver!/1`.
+ `:ok` always. Delivery errors are logged and not re-raised so they do not
+ crash the caller process (AshAuthentication ignores the return value).
"""
@impl true
def send(user, token, _) do
@@ -44,12 +47,24 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
locale: Gettext.get_locale(MvWeb.Gettext)
}
- new()
- |> from(Mailer.mail_from())
- |> to(to_string(user.email))
- |> subject(subject)
- |> put_view(MvWeb.EmailsView)
- |> render_body("user_confirmation.html", assigns)
- |> Mailer.deliver!()
+ email =
+ new()
+ |> from(Mailer.mail_from())
+ |> to(to_string(user.email))
+ |> subject(subject)
+ |> put_view(MvWeb.EmailsView)
+ |> render_body("user_confirmation.html", assigns)
+
+ case Mailer.deliver(email) do
+ {:ok, _} ->
+ :ok
+
+ {:error, reason} ->
+ Logger.error(
+ "Failed to send user confirmation email to #{user.email}: #{inspect(reason)}"
+ )
+
+ :ok
+ end
end
end
diff --git a/lib/mv/accounts/user/senders/send_password_reset_email.ex b/lib/mv/accounts/user/senders/send_password_reset_email.ex
index 74d5d47..e276e20 100644
--- a/lib/mv/accounts/user/senders/send_password_reset_email.ex
+++ b/lib/mv/accounts/user/senders/send_password_reset_email.ex
@@ -16,6 +16,8 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
import Swoosh.Email
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
+ require Logger
+
alias Mv.Mailer
@doc """
@@ -30,7 +32,8 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
- `_opts` - Additional options (unused)
## Returns
- The Swoosh.Email delivery result from `Mailer.deliver!/1`.
+ `:ok` always. Delivery errors are logged and not re-raised so they do not
+ crash the caller process (AshAuthentication ignores the return value).
"""
@impl true
def send(user, token, _) do
@@ -44,12 +47,21 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
locale: Gettext.get_locale(MvWeb.Gettext)
}
- new()
- |> from(Mailer.mail_from())
- |> to(to_string(user.email))
- |> subject(subject)
- |> put_view(MvWeb.EmailsView)
- |> render_body("password_reset.html", assigns)
- |> Mailer.deliver!()
+ email =
+ new()
+ |> from(Mailer.mail_from())
+ |> to(to_string(user.email))
+ |> subject(subject)
+ |> put_view(MvWeb.EmailsView)
+ |> render_body("password_reset.html", assigns)
+
+ case Mailer.deliver(email) do
+ {:ok, _} ->
+ :ok
+
+ {:error, reason} ->
+ Logger.error("Failed to send password reset email to #{user.email}: #{inspect(reason)}")
+ :ok
+ end
end
end
diff --git a/lib/mv/config.ex b/lib/mv/config.ex
index e176b8c..b824c1d 100644
--- a/lib/mv/config.ex
+++ b/lib/mv/config.ex
@@ -451,40 +451,191 @@ defmodule Mv.Config do
def oidc_only_env_set?, do: env_set?("OIDC_ONLY")
# ---------------------------------------------------------------------------
- # SMTP configuration (stubs for TDD – ENV overrides Settings; see docs/smtp-configuration-concept.md)
+ # SMTP configuration – ENV overrides Settings; see docs/smtp-configuration-concept.md
# ---------------------------------------------------------------------------
- @doc "Returns SMTP host. ENV SMTP_HOST overrides Settings. Stub: always nil until implemented."
+ @doc """
+ Returns SMTP host. ENV `SMTP_HOST` overrides Settings.
+ """
@spec smtp_host() :: String.t() | nil
- def smtp_host, do: nil
+ def smtp_host do
+ smtp_env_or_setting("SMTP_HOST", :smtp_host)
+ end
- @doc "Returns SMTP port (e.g. 587). ENV SMTP_PORT overrides Settings. Stub: always nil until implemented."
+ @doc """
+ Returns SMTP port as integer. ENV `SMTP_PORT` (parsed) overrides Settings.
+ Returns nil when neither ENV nor Settings provide a valid port.
+ """
@spec smtp_port() :: non_neg_integer() | nil
- def smtp_port, do: nil
+ def smtp_port do
+ case System.get_env("SMTP_PORT") do
+ nil ->
+ get_from_settings_integer(:smtp_port)
- @doc "Returns SMTP username. ENV SMTP_USERNAME overrides Settings. Stub: always nil until implemented."
+ value when is_binary(value) ->
+ case Integer.parse(String.trim(value)) do
+ {port, _} when port > 0 -> port
+ _ -> nil
+ end
+ end
+ end
+
+ @doc """
+ Returns SMTP username. ENV `SMTP_USERNAME` overrides Settings.
+ """
@spec smtp_username() :: String.t() | nil
- def smtp_username, do: nil
+ def smtp_username do
+ smtp_env_or_setting("SMTP_USERNAME", :smtp_username)
+ end
- @doc "Returns SMTP password. ENV SMTP_PASSWORD overrides SMTP_PASSWORD_FILE overrides Settings. Stub: always nil until implemented."
+ @doc """
+ Returns SMTP password.
+
+ Priority: `SMTP_PASSWORD` ENV > `SMTP_PASSWORD_FILE` (file contents) > Settings.
+ Strips trailing whitespace/newlines from file contents.
+ """
@spec smtp_password() :: String.t() | nil
- def smtp_password, do: nil
+ def smtp_password do
+ case System.get_env("SMTP_PASSWORD") do
+ nil -> smtp_password_from_file_or_settings()
+ value -> trim_nil(value)
+ end
+ end
- @doc "Returns SMTP TLS/SSL mode (e.g. 'tls', 'ssl', 'none'). Stub: always nil until implemented."
+ defp smtp_password_from_file_or_settings do
+ case System.get_env("SMTP_PASSWORD_FILE") do
+ nil -> get_smtp_password_from_settings()
+ path -> read_smtp_password_file(path)
+ end
+ end
+
+ defp read_smtp_password_file(path) do
+ case File.read(String.trim(path)) do
+ {:ok, content} -> trim_nil(content)
+ {:error, _} -> nil
+ end
+ end
+
+ @doc """
+ Returns SMTP TLS/SSL mode string (e.g. 'tls', 'ssl', 'none').
+ ENV `SMTP_SSL` overrides Settings.
+ """
@spec smtp_ssl() :: String.t() | nil
- def smtp_ssl, do: nil
+ def smtp_ssl do
+ smtp_env_or_setting("SMTP_SSL", :smtp_ssl)
+ end
- @doc "Returns true when SMTP is configured (e.g. host present). Stub: always false until implemented."
+ @doc """
+ Returns true when SMTP is configured (host present from ENV or Settings).
+ """
@spec smtp_configured?() :: boolean()
- def smtp_configured?, do: false
+ def smtp_configured? do
+ present?(smtp_host())
+ end
- @doc "Returns true when any SMTP ENV variable is set (for Settings UI hint). Stub: always false until implemented."
+ @doc """
+ Returns true when any SMTP ENV variable is set (used in Settings UI for hints).
+ """
@spec smtp_env_configured?() :: boolean()
- def smtp_env_configured?, do: false
+ def smtp_env_configured? do
+ smtp_host_env_set?() or smtp_port_env_set?() or smtp_username_env_set?() or
+ smtp_password_env_set?() or smtp_ssl_env_set?()
+ end
+ @doc "Returns true if SMTP_HOST ENV is set."
+ @spec smtp_host_env_set?() :: boolean()
def smtp_host_env_set?, do: env_set?("SMTP_HOST")
+
+ @doc "Returns true if SMTP_PORT ENV is set."
+ @spec smtp_port_env_set?() :: boolean()
def smtp_port_env_set?, do: env_set?("SMTP_PORT")
+
+ @doc "Returns true if SMTP_USERNAME ENV is set."
+ @spec smtp_username_env_set?() :: boolean()
def smtp_username_env_set?, do: env_set?("SMTP_USERNAME")
+
+ @doc "Returns true if SMTP_PASSWORD or SMTP_PASSWORD_FILE ENV is set."
+ @spec smtp_password_env_set?() :: boolean()
def smtp_password_env_set?, do: env_set?("SMTP_PASSWORD") or env_set?("SMTP_PASSWORD_FILE")
+
+ @doc "Returns true if SMTP_SSL ENV is set."
+ @spec smtp_ssl_env_set?() :: boolean()
def smtp_ssl_env_set?, do: env_set?("SMTP_SSL")
+
+ # ---------------------------------------------------------------------------
+ # Transactional email sender identity (mail_from)
+ # ENV variables MAIL_FROM_NAME / MAIL_FROM_EMAIL take priority; fallback to
+ # Settings smtp_from_name / smtp_from_email; final fallback: hardcoded defaults.
+ # ---------------------------------------------------------------------------
+
+ @doc """
+ Returns the display name for the transactional email sender.
+
+ Priority: `MAIL_FROM_NAME` ENV > Settings `smtp_from_name` > `"Mila"`.
+ """
+ @spec mail_from_name() :: String.t()
+ def mail_from_name do
+ case System.get_env("MAIL_FROM_NAME") do
+ nil -> get_from_settings(:smtp_from_name) || "Mila"
+ value -> trim_nil(value) || "Mila"
+ end
+ end
+
+ @doc """
+ Returns the email address for the transactional email sender.
+
+ Priority: `MAIL_FROM_EMAIL` ENV > Settings `smtp_from_email` > `nil`.
+ Returns `nil` when not configured (caller should fall back to a safe default).
+ """
+ @spec mail_from_email() :: String.t() | nil
+ def mail_from_email do
+ case System.get_env("MAIL_FROM_EMAIL") do
+ nil -> get_from_settings(:smtp_from_email)
+ value -> trim_nil(value)
+ end
+ end
+
+ @doc "Returns true if MAIL_FROM_NAME ENV is set."
+ @spec mail_from_name_env_set?() :: boolean()
+ def mail_from_name_env_set?, do: env_set?("MAIL_FROM_NAME")
+
+ @doc "Returns true if MAIL_FROM_EMAIL ENV is set."
+ @spec mail_from_email_env_set?() :: boolean()
+ def mail_from_email_env_set?, do: env_set?("MAIL_FROM_EMAIL")
+
+ # Reads a plain string SMTP setting: ENV first, then Settings.
+ defp smtp_env_or_setting(env_key, setting_key) do
+ case System.get_env(env_key) do
+ nil -> get_from_settings(setting_key)
+ value -> trim_nil(value)
+ end
+ end
+
+ # Reads an integer setting attribute from Settings.
+ defp get_from_settings_integer(key) do
+ case Mv.Membership.get_settings() do
+ {:ok, settings} ->
+ case Map.get(settings, key) do
+ v when is_integer(v) and v > 0 -> v
+ _ -> nil
+ end
+
+ {:error, _} ->
+ nil
+ end
+ end
+
+ # Reads the SMTP password directly from the DB via an explicit select,
+ # bypassing the standard read action which excludes smtp_password for security.
+ defp get_smtp_password_from_settings do
+ query = Ash.Query.select(Mv.Membership.Setting, [:id, :smtp_password])
+
+ case Ash.read_one(query, authorize?: false, domain: Mv.Membership) do
+ {:ok, settings} when not is_nil(settings) ->
+ settings |> Map.get(:smtp_password) |> trim_nil()
+
+ _ ->
+ nil
+ end
+ end
end
diff --git a/lib/mv/mailer.ex b/lib/mv/mailer.ex
index e78735b..8fca77b 100644
--- a/lib/mv/mailer.ex
+++ b/lib/mv/mailer.ex
@@ -4,27 +4,194 @@ defmodule Mv.Mailer do
Use `mail_from/0` for the configured sender address (join confirmation,
user confirmation, password reset).
+
+ ## Sender identity
+
+ The "from" address is determined by priority:
+ 1. `MAIL_FROM_EMAIL` / `MAIL_FROM_NAME` environment variables
+ 2. Settings database (`smtp_from_email`, `smtp_from_name`)
+ 3. Hardcoded default (`"Mila"`, `"noreply@example.com"`)
+
+ **Important:** On most SMTP servers the sender email must be owned by the
+ authenticated SMTP user. Set `smtp_from_email` to the same address as
+ `smtp_username` (or an alias allowed by the server).
+
+ ## SMTP adapter configuration
+
+ The SMTP adapter can be configured via:
+ - **Environment variables** at boot (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`,
+ `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) — configured in `runtime.exs`.
+ - **Admin Settings** (database) — read at send time via `Mv.Config.smtp_*()` helpers.
+ Settings-based config is passed per-send via `smtp_config/0`.
+
+ ENV takes priority over Settings (same pattern as OIDC and Vereinfacht).
"""
use Swoosh.Mailer, otp_app: :mv
- @doc """
- Returns the configured "from" address for transactional emails.
+ import Swoosh.Email
+ use Gettext, backend: MvWeb.Gettext, otp_app: :mv
- Configure in config.exs or runtime.exs as `config :mv, :mail_from, {name, email}`.
- Default: `{"Mila", "noreply@example.com"}`.
+ require Logger
+
+ @email_regex ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/
+
+ @doc """
+ Returns the configured "from" address for transactional emails as `{name, email}`.
+
+ Priority: ENV `MAIL_FROM_NAME`/`MAIL_FROM_EMAIL` > Settings `smtp_from_name`/`smtp_from_email` > defaults.
"""
+ @spec mail_from() :: {String.t(), String.t()}
def mail_from do
- Application.get_env(:mv, :mail_from, {"Mila", "noreply@example.com"})
+ {Mv.Config.mail_from_name(), Mv.Config.mail_from_email() || "noreply@example.com"}
end
@doc """
Sends a test email to the given address. Used from Global Settings SMTP section.
- Returns `{:ok, email}` on success, `{:error, reason}` on failure (e.g. invalid address,
- SMTP not configured, connection error). Stub: always returns error until implemented.
+ Returns `{:ok, email}` on success, `{:error, reason}` on failure.
+ The `reason` is a classified atom for known error categories, or `{:smtp_error, message}`
+ for SMTP-level errors with a human-readable message, or the raw term for unknown errors.
"""
- @spec send_test_email(String.t()) :: {:ok, Swoosh.Email.t()} | {:error, term()}
- def send_test_email(_to_email) do
- {:error, :not_implemented}
+ @spec send_test_email(String.t()) ::
+ {:ok, Swoosh.Email.t()} | {:error, atom() | {:smtp_error, String.t()} | term()}
+ def send_test_email(to_email) when is_binary(to_email) do
+ if valid_email?(to_email) do
+ subject = gettext("Mila – Test email")
+
+ body =
+ gettext(
+ "This is a test email sent from Mila. If you received this, your SMTP configuration is working correctly."
+ )
+
+ email =
+ new()
+ |> from(mail_from())
+ |> to(to_email)
+ |> subject(subject)
+ |> text_body(body)
+ |> html_body("
#{body}
")
+
+ case deliver(email, smtp_config()) do
+ {:ok, _} = ok ->
+ ok
+
+ {:error, reason} ->
+ classified = classify_smtp_error(reason)
+ Logger.warning("SMTP test email failed: #{inspect(reason)}")
+ {:error, classified}
+ end
+ else
+ {:error, :invalid_email_address}
+ end
end
+
+ def send_test_email(_), do: {:error, :invalid_email_address}
+
+ @doc """
+ Builds the per-send SMTP config from `Mv.Config` when SMTP is configured via
+ Settings only (not boot-time ENV). Returns an empty list when the mailer is
+ already configured at boot (ENV-based), so Swoosh uses the Application config.
+
+ The return value must be a flat keyword list (adapter, relay, port, ...).
+ Swoosh merges it with Application config; top-level keys override the mailer's
+ default adapter (e.g. Local in dev), so this delivery uses SMTP.
+ """
+ @spec smtp_config() :: keyword()
+ def smtp_config do
+ if Mv.Config.smtp_configured?() and not boot_smtp_configured?() do
+ host = Mv.Config.smtp_host()
+ port = Mv.Config.smtp_port() || 587
+ username = Mv.Config.smtp_username()
+ password = Mv.Config.smtp_password()
+ ssl_mode = Mv.Config.smtp_ssl() || "tls"
+
+ [
+ adapter: Swoosh.Adapters.SMTP,
+ relay: host,
+ port: port,
+ ssl: ssl_mode == "ssl",
+ tls: if(ssl_mode == "tls", do: :always, else: :never),
+ auth: :always,
+ username: username,
+ password: password,
+ # OTP 26+ enforces verify_peer; allow self-signed / internal certs.
+ # tls_options: STARTTLS upgrade (port 587); sockopts: direct SSL connect (port 465).
+ tls_options: [verify: :verify_none],
+ sockopts: [verify: :verify_none]
+ ]
+ |> Enum.reject(fn {_k, v} -> is_nil(v) end)
+ else
+ []
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # SMTP error classification
+ # Maps raw gen_smtp error terms to human-readable atoms / structs.
+ # ---------------------------------------------------------------------------
+
+ @doc false
+ @spec classify_smtp_error(term()) ::
+ :sender_rejected
+ | :auth_failed
+ | :recipient_rejected
+ | :tls_failed
+ | :connection_failed
+ | {:smtp_error, String.t()}
+ | term()
+ def classify_smtp_error({:retries_exceeded, {:temporary_failure, _host, :tls_failed}}),
+ do: :tls_failed
+
+ def classify_smtp_error({:retries_exceeded, {:network_failure, _host, _}}),
+ do: :connection_failed
+
+ def classify_smtp_error({:send, {:permanent_failure, _host, msg}}) do
+ str = if is_list(msg), do: List.to_string(msg), else: to_string(msg)
+ classify_permanent_failure_message(str)
+ end
+
+ def classify_smtp_error(reason), do: reason
+
+ # ---------------------------------------------------------------------------
+ # Private helpers
+ # ---------------------------------------------------------------------------
+
+ defp classify_permanent_failure_message(str) do
+ cond do
+ smtp_auth_failure?(str) -> :auth_failed
+ smtp_sender_rejected?(str) -> :sender_rejected
+ smtp_recipient_rejected?(str) -> :recipient_rejected
+ true -> {:smtp_error, String.trim(str)}
+ end
+ end
+
+ defp smtp_auth_failure?(str),
+ do:
+ String.contains?(str, "535") or String.contains?(str, "authentication") or
+ String.contains?(str, "Authentication")
+
+ defp smtp_sender_rejected?(str),
+ do:
+ String.contains?(str, "553") or String.contains?(str, "Sender address rejected") or
+ String.contains?(str, "not owned")
+
+ defp smtp_recipient_rejected?(str),
+ do:
+ String.contains?(str, "550") or String.contains?(str, "No such user") or
+ String.contains?(str, "no such user") or String.contains?(str, "User unknown")
+
+ # Returns true when the SMTP adapter has been configured at boot time via ENV
+ # (i.e. the Application config is already set to the SMTP adapter).
+ defp boot_smtp_configured? do
+ case Application.get_env(:mv, __MODULE__) do
+ config when is_list(config) -> Keyword.get(config, :adapter) == Swoosh.Adapters.SMTP
+ _ -> false
+ end
+ end
+
+ defp valid_email?(email) when is_binary(email) do
+ Regex.match?(@email_regex, String.trim(email))
+ end
+
+ defp valid_email?(_), do: false
end
diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex
index 3c75fa8..2662dd1 100644
--- a/lib/mv_web/live/global_settings_live.ex
+++ b/lib/mv_web/live/global_settings_live.ex
@@ -77,6 +77,18 @@ defmodule MvWeb.GlobalSettingsLive do
|> assign(:oidc_only_env_set, Mv.Config.oidc_only_env_set?())
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|> assign(:oidc_client_secret_set, present?(settings.oidc_client_secret))
+ |> assign(:smtp_env_configured, Mv.Config.smtp_env_configured?())
+ |> assign(:smtp_host_env_set, Mv.Config.smtp_host_env_set?())
+ |> assign(:smtp_port_env_set, Mv.Config.smtp_port_env_set?())
+ |> assign(:smtp_username_env_set, Mv.Config.smtp_username_env_set?())
+ |> assign(:smtp_password_env_set, Mv.Config.smtp_password_env_set?())
+ |> assign(:smtp_ssl_env_set, Mv.Config.smtp_ssl_env_set?())
+ |> assign(:smtp_from_name_env_set, Mv.Config.mail_from_name_env_set?())
+ |> assign(:smtp_from_email_env_set, Mv.Config.mail_from_email_env_set?())
+ |> assign(:smtp_password_set, present?(Mv.Config.smtp_password()))
+ |> assign(:smtp_configured, Mv.Config.smtp_configured?())
+ |> assign(:smtp_test_result, nil)
+ |> assign(:smtp_test_to_email, "")
|> assign_join_form_state(settings, custom_fields)
|> assign_form()
@@ -137,21 +149,6 @@ defmodule MvWeb.GlobalSettingsLive do
- <%!-- Board approval (future feature) --%>
-
-
-
-
-
<%!-- Field list header + Add button (left-aligned) --%>
{gettext("Fields on the join form")}
@@ -269,6 +266,181 @@ defmodule MvWeb.GlobalSettingsLive do
+ <%!-- SMTP / E-Mail Section --%>
+ <.form_section title={gettext("SMTP / E-Mail")}>
+ <%= if @smtp_env_configured do %>
+
+ {gettext("Some values are set via environment variables. Those fields are read-only.")}
+
+ <% end %>
+
+ <%= if Mix.env() == :prod and not @smtp_configured do %>
+
+ <.icon name="hero-exclamation-triangle" class="size-5 shrink-0 mt-0.5" />
+
+ {gettext(
+ "SMTP is not configured. Transactional emails (join confirmation, password reset, etc.) will not be delivered reliably."
+ )}
+
+
+ {gettext(
+ "The sender email must be owned by or authorized for the SMTP user on most servers."
+ )}
+
+ <.button
+ :if={
+ not (@smtp_host_env_set and @smtp_port_env_set and @smtp_username_env_set and
+ @smtp_password_env_set and @smtp_ssl_env_set and @smtp_from_email_env_set and
+ @smtp_from_name_env_set)
+ }
+ phx-disable-with={gettext("Saving...")}
+ variant="primary"
+ class="mt-2"
+ >
+ {gettext("Save SMTP Settings")}
+
+
+
+ <%!-- Test email: use form phx-submit so the current input value is always sent (e.g. after paste without blur) --%>
+
+ """
+ end
+
+ defp smtp_test_result(%{result: {:error, :invalid_email_address}} = assigns) do
+ ~H"""
+
+ <.icon name="hero-x-circle" class="size-5 shrink-0" />
+ {gettext("Invalid email address. Please enter a valid recipient address.")}
+
+ """
+ end
+
+ defp smtp_test_result(%{result: {:error, :not_implemented}} = assigns) do
+ ~H"""
+
+ <.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
+ {gettext("SMTP is not configured. Please set at least the SMTP host.")}
+
+ """
+ end
+
+ defp smtp_test_result(%{result: {:error, :sender_rejected}} = assigns) do
+ ~H"""
+
+ <.icon name="hero-x-circle" class="size-5 shrink-0" />
+
+ {gettext(
+ "Sender address rejected. The \"Sender email\" must be owned by or authorized for the SMTP user."
+ )}
+
+
+ """
+ end
+
+ defp smtp_test_result(%{result: {:error, :auth_failed}} = assigns) do
+ ~H"""
+
+ <.icon name="hero-x-circle" class="size-5 shrink-0" />
+
+ {gettext("Authentication failed. Please check the SMTP username and password.")}
+
+
+ """
+ end
+
+ defp smtp_test_result(%{result: {:error, :recipient_rejected}} = assigns) do
+ ~H"""
+
+ <.icon name="hero-x-circle" class="size-5 shrink-0" />
+ {gettext("Recipient address rejected by the server.")}
+
+ """
+ end
+
+ defp smtp_test_result(%{result: {:error, :tls_failed}} = assigns) do
+ ~H"""
+
+ <.icon name="hero-x-circle" class="size-5 shrink-0" />
+
+ {gettext(
+ "TLS connection failed. Check the TLS/SSL setting and port (587 for TLS, 465 for SSL)."
+ )}
+
+
+ """
+ end
+
+ defp smtp_test_result(%{result: {:error, :connection_failed}} = assigns) do
+ ~H"""
+