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") -- 2.47.2 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." + )} + +
+ <% end %> + + <.form for={@form} id="smtp-form" phx-change="validate" phx-submit="save"> +
+ <.input + field={@form[:smtp_host]} + type="text" + label={gettext("Host")} + disabled={@smtp_host_env_set} + placeholder={ + if(@smtp_host_env_set, + do: gettext("From SMTP_HOST"), + else: "smtp.example.com" + ) + } + /> + <.input + field={@form[:smtp_port]} + type="number" + label={gettext("Port")} + disabled={@smtp_port_env_set} + placeholder={if(@smtp_port_env_set, do: gettext("From SMTP_PORT"), else: "587")} + /> + <.input + field={@form[:smtp_username]} + type="text" + label={gettext("Username")} + disabled={@smtp_username_env_set} + placeholder={ + if(@smtp_username_env_set, + do: gettext("From SMTP_USERNAME"), + else: "user@example.com" + ) + } + /> +
+ + <.input + field={@form[:smtp_password]} + type="password" + label="" + disabled={@smtp_password_env_set} + placeholder={ + if(@smtp_password_env_set, + do: gettext("From SMTP_PASSWORD"), + else: + if(@smtp_password_set, + do: gettext("Leave blank to keep current"), + else: nil + ) + ) + } + /> +
+ <.input + field={@form[:smtp_ssl]} + type="select" + label={gettext("TLS/SSL")} + disabled={@smtp_ssl_env_set} + options={[ + {gettext("TLS (port 587, recommended)"), "tls"}, + {gettext("SSL (port 465)"), "ssl"}, + {gettext("None (port 25, insecure)"), "none"} + ]} + placeholder={if(@smtp_ssl_env_set, do: gettext("From SMTP_SSL"), else: nil)} + /> + <.input + field={@form[:smtp_from_email]} + type="email" + label={gettext("Sender email (From)")} + disabled={@smtp_from_email_env_set} + placeholder={ + if(@smtp_from_email_env_set, + do: gettext("From MAIL_FROM_EMAIL"), + else: "noreply@example.com" + ) + } + /> + <.input + field={@form[:smtp_from_name]} + type="text" + label={gettext("Sender name (From)")} + disabled={@smtp_from_name_env_set} + placeholder={ + if(@smtp_from_name_env_set, do: gettext("From MAIL_FROM_NAME"), else: "Mila") + } + /> +
+

+ {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) --%> +
+

{gettext("Test email")}

+ <.form + for={%{}} + id="smtp-test-email-form" + data-testid="smtp-test-email-form" + phx-submit="send_smtp_test_email" + class="space-y-3" + > +
+
+ + +
+ <.button + type="submit" + variant="outline" + data-testid="smtp-send-test-email" + phx-disable-with={gettext("Sending...")} + > + {gettext("Send test email")} + +
+ + <%= if @smtp_test_result do %> +
+ <.smtp_test_result result={@smtp_test_result} /> +
+ <% end %> +
+ + <%!-- Vereinfacht Integration Section --%> <.form_section title={gettext("Vereinfacht Integration")}> <%= if @vereinfacht_env_configured do %> @@ -516,6 +688,30 @@ defmodule MvWeb.GlobalSettingsLive do assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))} end + # phx-change can fire with only _target (e.g. when focusing a field); avoid FunctionClauseError + def handle_event("validate", params, socket) when is_map(params) do + setting_params = + params["setting"] || Map.get(socket.assigns.form.params || %{}, "setting") || %{} + + {:noreply, + assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))} + end + + @impl true + def handle_event("update_smtp_test_to_email", %{"to_email" => email}, socket) do + {:noreply, assign(socket, :smtp_test_to_email, email)} + end + + @impl true + def handle_event("send_smtp_test_email", params, socket) do + to_email = + (params["to_email"] || socket.assigns.smtp_test_to_email || "") + |> String.trim() + + result = Mv.Mailer.send_test_email(to_email) + {:noreply, assign(socket, :smtp_test_result, result)} + end + @impl true def handle_event("test_vereinfacht_connection", _params, socket) do result = Mv.Vereinfacht.test_connection() @@ -560,11 +756,13 @@ defmodule MvWeb.GlobalSettingsLive do @impl true def handle_event("save", %{"setting" => setting_params}, socket) do actor = MvWeb.LiveHelpers.current_actor(socket) - # Never send blank API key / client secret so we do not overwrite stored secrets + + # Never send blank API key / client secret / smtp password so we do not overwrite stored secrets setting_params_clean = setting_params |> drop_blank_vereinfacht_api_key() |> drop_blank_oidc_client_secret() + |> drop_blank_smtp_password() saves_vereinfacht = vereinfacht_params?(setting_params_clean) @@ -581,6 +779,10 @@ defmodule MvWeb.GlobalSettingsLive do |> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key)) |> assign(:oidc_client_secret_set, present?(fresh_settings.oidc_client_secret)) |> assign(:oidc_configured, Mv.Config.oidc_configured?()) + |> assign(:smtp_configured, Mv.Config.smtp_configured?()) + |> assign(:smtp_password_set, present?(Mv.Config.smtp_password())) + |> 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(:vereinfacht_test_result, test_result) |> put_flash(:success, gettext("Settings updated successfully")) |> assign_form() @@ -760,17 +962,29 @@ defmodule MvWeb.GlobalSettingsLive do end end + defp drop_blank_smtp_password(params) when is_map(params) do + case params do + %{"smtp_password" => v} when v in [nil, ""] -> + Map.delete(params, "smtp_password") + + _ -> + params + end + end + defp assign_form(%{assigns: %{settings: settings}} = socket) do - # Show ENV values in disabled fields (Vereinfacht and OIDC); never expose API key / client secret + # Show ENV values in disabled fields (Vereinfacht, OIDC, SMTP); never expose secrets in form settings_display = settings |> merge_vereinfacht_env_values() |> merge_oidc_env_values() + |> merge_smtp_env_values() settings_for_form = %{ settings_display | vereinfacht_api_key: nil, - oidc_client_secret: nil + oidc_client_secret: nil, + smtp_password: nil } form = @@ -845,6 +1059,28 @@ defmodule MvWeb.GlobalSettingsLive do end end + defp merge_smtp_env_values(s) do + s + |> put_if_env_set(:smtp_host, Mv.Config.smtp_host_env_set?(), Mv.Config.smtp_host()) + |> put_if_env_set(:smtp_port, Mv.Config.smtp_port_env_set?(), Mv.Config.smtp_port()) + |> put_if_env_set( + :smtp_username, + Mv.Config.smtp_username_env_set?(), + Mv.Config.smtp_username() + ) + |> put_if_env_set(:smtp_ssl, Mv.Config.smtp_ssl_env_set?(), Mv.Config.smtp_ssl()) + |> put_if_env_set( + :smtp_from_email, + Mv.Config.mail_from_email_env_set?(), + Mv.Config.mail_from_email() + ) + |> put_if_env_set( + :smtp_from_name, + Mv.Config.mail_from_name_env_set?(), + Mv.Config.mail_from_name() + ) + end + defp enrich_sync_errors([]), do: [] defp enrich_sync_errors(errors) when is_list(errors) do @@ -1018,6 +1254,115 @@ defmodule MvWeb.GlobalSettingsLive do """ end + # ---- SMTP test result component ---- + + attr :result, :any, required: true + + defp smtp_test_result(%{result: {:ok, _}} = assigns) do + ~H""" +
+ <.icon name="hero-check-circle" class="size-5 shrink-0" /> + {gettext("Test email sent successfully.")} +
+ """ + 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""" +
+ <.icon name="hero-x-circle" class="size-5 shrink-0" /> + + {gettext("Server unreachable. Check host and port.")} + +
+ """ + end + + defp smtp_test_result(%{result: {:error, {:smtp_error, message}}} = assigns) + when is_binary(message) do + ~H""" +
+ <.icon name="hero-x-circle" class="size-5 shrink-0" /> + + {gettext("SMTP error:")} {@result |> elem(1) |> elem(1)} + +
+ """ + end + + defp smtp_test_result(%{result: {:error, _reason}} = assigns) do + ~H""" +
+ <.icon name="hero-x-circle" class="size-5 shrink-0" /> + {gettext("Failed to send test email. Please check your SMTP configuration.")} +
+ """ + end + # ---- Join form helper functions ---- defp assign_join_form_state(socket, settings, custom_fields) do diff --git a/mix.exs b/mix.exs index 56e7dde..29dbc25 100644 --- a/mix.exs +++ b/mix.exs @@ -67,6 +67,8 @@ defmodule Mv.MixProject do depth: 1}, {:phoenix_swoosh, "~> 1.0"}, {:swoosh, "~> 1.16"}, + # Required by Swoosh.Adapters.SMTP (and its Helpers use mimemail, which gen_smtp brings in) + {:gen_smtp, "~> 1.0"}, {:req, "~> 0.5"}, {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"}, diff --git a/mix.lock b/mix.lock index 8ac995a..b177796 100644 --- a/mix.lock +++ b/mix.lock @@ -35,6 +35,7 @@ "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, + "gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"}, "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "hammer": {:hex, :hammer, "7.2.0", "73113eca87f0fd20a6d3679c1182e8c4c1778266f61de4e9dc8c589dee156c30", [:mix], [], "hexpm", "c50fa865ddfe7b3d4f8a6941f56940679e02a9a1465b00668a95d140b101d828"}, diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 055f36a..9c94d0e 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -461,6 +461,7 @@ msgstr "Sonderzeichen empfohlen" msgid "Include both letters and numbers" msgstr "Buchstaben und Zahlen verwenden" +#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format @@ -3391,11 +3392,6 @@ msgstr "Keine Felder ausgewählt. Füge mindestens das E-Mail-Feld hinzu." msgid "Remove field %{label}" msgstr "Feld %{label} entfernen" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Board approval required (in development)" -msgstr "Bestätigung durch Vorstand erforderlich (in Entwicklung)" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Individual fields" @@ -3623,3 +3619,183 @@ msgstr "Offene Anträge" #, elixir-autogen, elixir-format msgid "Review by" msgstr "Geprüft von" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Failed to send test email. Please check your SMTP configuration." +msgstr "Test-E-Mail konnte nicht gesendet werden. Bitte prüfe deine SMTP-Konfiguration." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From SMTP_HOST" +msgstr "Von SMTP_HOST" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From SMTP_PASSWORD" +msgstr "Von SMTP_PASSWORD" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From SMTP_PORT" +msgstr "Von SMTP_PORT" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From SMTP_SSL" +msgstr "Von SMTP_SSL" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From SMTP_USERNAME" +msgstr "Von SMTP_USERNAME" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Host" +msgstr "Host" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Invalid email address. Please enter a valid recipient address." +msgstr "Ungültige E-Mail-Adresse. Bitte gib eine gültige Empfängeradresse ein." + +#: lib/mv/mailer.ex +#, elixir-autogen, elixir-format +msgid "Mila – Test email" +msgstr "Mila – Test-E-Mail" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "None (port 25, insecure)" +msgstr "Keines (Port 25, unsicher)" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Port" +msgstr "Port" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Recipient" +msgstr "Empfänger*in" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP / E-Mail" +msgstr "SMTP / E-Mail" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP is not configured. Please set at least the SMTP host." +msgstr "SMTP ist nicht konfiguriert. Bitte setze mindestens den SMTP-Host." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP is not configured. Transactional emails (join confirmation, password reset, etc.) will not be delivered reliably." +msgstr "SMTP ist nicht konfiguriert. Transaktions-E-Mails (Beitrittsbestätigung, Passwort-Reset usw.) werden möglicherweise nicht zuverlässig zugestellt." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SSL (port 465)" +msgstr "SSL (Port 465)" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Save SMTP Settings" +msgstr "SMTP-Einstellungen speichern" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Send test email" +msgstr "Test-E-Mail senden" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Sending..." +msgstr "Sende..." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "TLS (port 587, recommended)" +msgstr "TLS (Port 587, empfohlen)" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "TLS/SSL" +msgstr "TLS/SSL" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Test email" +msgstr "Test-E-Mail" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Test email sent successfully." +msgstr "Test-E-Mail erfolgreich gesendet." + +#: lib/mv/mailer.ex +#, elixir-autogen, elixir-format +msgid "This is a test email sent from Mila. If you received this, your SMTP configuration is working correctly." +msgstr "Dies ist eine Test-E-Mail von Mila. Wenn du diese erhalten hast, funktioniert deine SMTP-Konfiguration korrekt." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Username" +msgstr "Benutzername" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Authentication failed. Please check the SMTP username and password." +msgstr "Authentifizierung fehlgeschlagen. Bitte Benutzername und Passwort prüfen." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From MAIL_FROM_EMAIL" +msgstr "Aus MAIL_FROM_EMAIL" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From MAIL_FROM_NAME" +msgstr "Aus MAIL_FROM_NAME" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Recipient address rejected by the server." +msgstr "Empfängeradresse vom Server abgelehnt." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP error:" +msgstr "SMTP-Fehler:" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Sender address rejected. The \"Sender email\" must be owned by or authorized for the SMTP user." +msgstr "Absenderadresse abgelehnt. Die \"Absender-E-Mail\" muss dem SMTP-Nutzer gehören oder für ihn erlaubt sein." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Sender email (From)" +msgstr "Absender-E-Mail (Von)" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Sender name (From)" +msgstr "Absendername (Von)" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Server unreachable. Check host and port." +msgstr "Server nicht erreichbar. Host und Port prüfen." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "TLS connection failed. Check the TLS/SSL setting and port (587 for TLS, 465 for SSL)." +msgstr "TLS-Verbindung fehlgeschlagen. TLS/SSL-Einstellung und Port prüfen (587 für TLS, 465 für SSL)." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "The sender email must be owned by or authorized for the SMTP user on most servers." +msgstr "Die Absender-E-Mail muss auf den meisten SMTP-Servern dem SMTP-Nutzer gehören oder für ihn erlaubt sein." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index a1e0909..1379299 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -462,6 +462,7 @@ msgstr "" msgid "Include both letters and numbers" msgstr "" +#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format @@ -3391,11 +3392,6 @@ msgstr "" msgid "Remove field %{label}" msgstr "" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Board approval required (in development)" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Individual fields" @@ -3623,3 +3619,183 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Review by" msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Failed to send test email. Please check your SMTP configuration." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From SMTP_HOST" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From SMTP_PASSWORD" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From SMTP_PORT" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From SMTP_SSL" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From SMTP_USERNAME" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Host" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Invalid email address. Please enter a valid recipient address." +msgstr "" + +#: lib/mv/mailer.ex +#, elixir-autogen, elixir-format +msgid "Mila – Test email" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "None (port 25, insecure)" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Port" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Recipient" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP / E-Mail" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP is not configured. Please set at least the SMTP host." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP is not configured. Transactional emails (join confirmation, password reset, etc.) will not be delivered reliably." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SSL (port 465)" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Save SMTP Settings" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Send test email" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Sending..." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "TLS (port 587, recommended)" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "TLS/SSL" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Test email" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Test email sent successfully." +msgstr "" + +#: lib/mv/mailer.ex +#, elixir-autogen, elixir-format +msgid "This is a test email sent from Mila. If you received this, your SMTP configuration is working correctly." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Username" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Authentication failed. Please check the SMTP username and password." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From MAIL_FROM_EMAIL" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From MAIL_FROM_NAME" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Recipient address rejected by the server." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP error:" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Sender address rejected. The \"Sender email\" must be owned by or authorized for the SMTP user." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Sender email (From)" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Sender name (From)" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Server unreachable. Check host and port." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "TLS connection failed. Check the TLS/SSL setting and port (587 for TLS, 465 for SSL)." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "The sender email must be owned by or authorized for the SMTP user on most servers." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index eccae34..a83ef1f 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -462,6 +462,7 @@ msgstr "" msgid "Include both letters and numbers" msgstr "" +#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format @@ -3391,11 +3392,6 @@ msgstr "" msgid "Remove field %{label}" msgstr "" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Board approval required (in development)" -msgstr "Board approval required (in development)" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Individual fields" @@ -3623,3 +3619,183 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Review by" msgstr "Review by" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Failed to send test email. Please check your SMTP configuration." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From SMTP_HOST" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From SMTP_PASSWORD" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From SMTP_PORT" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From SMTP_SSL" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From SMTP_USERNAME" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Host" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Invalid email address. Please enter a valid recipient address." +msgstr "" + +#: lib/mv/mailer.ex +#, elixir-autogen, elixir-format +msgid "Mila – Test email" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "None (port 25, insecure)" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Port" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Recipient" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP / E-Mail" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP is not configured. Please set at least the SMTP host." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP is not configured. Transactional emails (join confirmation, password reset, etc.) will not be delivered reliably." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SSL (port 465)" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Save SMTP Settings" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Send test email" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Sending..." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "TLS (port 587, recommended)" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "TLS/SSL" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Test email" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Test email sent successfully." +msgstr "" + +#: lib/mv/mailer.ex +#, elixir-autogen, elixir-format +msgid "This is a test email sent from Mila. If you received this, your SMTP configuration is working correctly." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Username" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Authentication failed. Please check the SMTP username and password." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From MAIL_FROM_EMAIL" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From MAIL_FROM_NAME" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Recipient address rejected by the server." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP error:" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Sender address rejected. The \"Sender email\" must be owned by or authorized for the SMTP user." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Sender email (From)" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Sender name (From)" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Server unreachable. Check host and port." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "TLS connection failed. Check the TLS/SSL setting and port (587 for TLS, 465 for SSL)." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "The sender email must be owned by or authorized for the SMTP user on most servers." +msgstr "" diff --git a/priv/repo/migrations/20260311082352_add_smtp_to_settings.exs b/priv/repo/migrations/20260311082352_add_smtp_to_settings.exs new file mode 100644 index 0000000..2439035 --- /dev/null +++ b/priv/repo/migrations/20260311082352_add_smtp_to_settings.exs @@ -0,0 +1,27 @@ +defmodule Mv.Repo.Migrations.AddSmtpToSettings do + @moduledoc """ + Adds SMTP configuration attributes to the settings table. + """ + + use Ecto.Migration + + def up do + alter table(:settings) do + add :smtp_host, :text + add :smtp_port, :bigint + add :smtp_username, :text + add :smtp_password, :text + add :smtp_ssl, :text + end + end + + def down do + alter table(:settings) do + remove :smtp_ssl + remove :smtp_password + remove :smtp_username + remove :smtp_port + remove :smtp_host + end + end +end diff --git a/priv/repo/migrations/20260311140000_add_mail_from_to_settings.exs b/priv/repo/migrations/20260311140000_add_mail_from_to_settings.exs new file mode 100644 index 0000000..c680763 --- /dev/null +++ b/priv/repo/migrations/20260311140000_add_mail_from_to_settings.exs @@ -0,0 +1,18 @@ +defmodule Mv.Repo.Migrations.AddMailFromToSettings do + @moduledoc "Adds smtp_from_name and smtp_from_email attributes to the settings table." + use Ecto.Migration + + def up do + alter table(:settings) do + add :smtp_from_name, :text + add :smtp_from_email, :text + end + end + + def down do + alter table(:settings) do + remove :smtp_from_email + remove :smtp_from_name + end + end +end diff --git a/priv/resource_snapshots/repo/join_requests/20260311082353.json b/priv/resource_snapshots/repo/join_requests/20260311082353.json new file mode 100644 index 0000000..26b6310 --- /dev/null +++ b/priv/resource_snapshots/repo/join_requests/20260311082353.json @@ -0,0 +1,243 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "\"pending_confirmation\"", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "status", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "email", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "first_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "form_data", + "type": "map" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "schema_version", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "confirmation_token_hash", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "confirmation_token_expires_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "confirmation_sent_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "submitted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "approved_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "rejected_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "join_requests_reviewed_by_user_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "users" + }, + "scale": null, + "size": null, + "source": "reviewed_by_user_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "source", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "F01A57710F9E6C9CF0E006B3B956AE5930D2C12FC502BF31683BEB3A75094BD8", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "join_requests" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/members/20260311082354.json b/priv/resource_snapshots/repo/members/20260311082354.json new file mode 100644 index 0000000..8795bdc --- /dev/null +++ b/priv/resource_snapshots/repo/members/20260311082354.json @@ -0,0 +1,246 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"uuid_generate_v7()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "first_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "email", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "join_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "exit_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "notes", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "city", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "street", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "house_number", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "postal_code", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "country", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "search_vector", + "type": "tsvector" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "membership_fee_start_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "vereinfacht_contact_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "members_membership_fee_type_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "membership_fee_types" + }, + "scale": null, + "size": null, + "source": "membership_fee_type_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "F704B80F108D01A7DF0C3B973FC94DBD778BD5555219BADB3C84EF1C91D9A3EF", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "members_unique_email_index", + "keys": [ + { + "type": "atom", + "value": "email" + } + ], + "name": "unique_email", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "members" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/settings/20260311082355.json b/priv/resource_snapshots/repo/settings/20260311082355.json new file mode 100644 index 0000000..099c8ef --- /dev/null +++ b/priv/resource_snapshots/repo/settings/20260311082355.json @@ -0,0 +1,347 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "club_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "member_field_visibility", + "type": "map" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "member_field_required", + "type": "map" + }, + { + "allow_nil?": false, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "include_joining_cycle", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "default_membership_fee_type_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "vereinfacht_api_url", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "vereinfacht_api_key", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "vereinfacht_club_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "vereinfacht_app_url", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "oidc_client_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "oidc_base_url", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "oidc_redirect_uri", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "oidc_client_secret", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "oidc_admin_group_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "oidc_groups_claim", + "type": "text" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "oidc_only", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "smtp_host", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "smtp_port", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "smtp_username", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "smtp_password", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "smtp_ssl", + "type": "text" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "join_form_enabled", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "[]", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "join_form_field_ids", + "type": [ + "array", + "text" + ] + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "join_form_field_required", + "type": "map" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "DDF99732D268EDCACB5F61CAA53B24F1EAA8EE2F54F4A31A2FB3FEF8DDC8BFAF", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "settings" +} \ No newline at end of file diff --git a/test/membership/setting_smtp_test.exs b/test/membership/setting_smtp_test.exs index ea4a954..b4c4e70 100644 --- a/test/membership/setting_smtp_test.exs +++ b/test/membership/setting_smtp_test.exs @@ -8,7 +8,6 @@ defmodule Mv.Membership.SettingSmtpTest do """ use Mv.DataCase, async: false - alias Mv.Helpers.SystemActor alias Mv.Membership setup do diff --git a/test/mv/mailer_test.exs b/test/mv/mailer_test.exs index 22cc49f..b5db447 100644 --- a/test/mv/mailer_test.exs +++ b/test/mv/mailer_test.exs @@ -37,9 +37,10 @@ defmodule Mv.MailerTest do 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 + {_name, expected_from} = Mailer.mail_from() + # email.from is a single {name, address} tuple in Swoosh, not a list + {_name, actual_from} = email.from + actual_from == expected_from 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 0cb4ead..e48c44b 100644 --- a/test/mv_web/live/global_settings_live_test.exs +++ b/test/mv_web/live/global_settings_live_test.exs @@ -89,19 +89,16 @@ defmodule MvWeb.GlobalSettingsLiveTest do 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 + # Submit the test-email form (phx-submit) with a valid recipient address 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 + |> form("[data-testid='smtp-test-email-form']", %{"to_email" => "test@example.com"}) + |> render_submit() + + # Result area must appear regardless of success or error 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 @@ -109,7 +106,7 @@ defmodule MvWeb.GlobalSettingsLiveTest do 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") + {:ok, _view, html} = live(conn, ~p"/settings") assert html =~ "SMTP" or html =~ "E-Mail" or html =~ "Settings" end end -- 2.47.2 From 942f2afd9ec765c02e752ac09b71a2272d82694c Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 12 Mar 2026 15:29:54 +0100 Subject: [PATCH 3/4] refactor: adress review --- CODE_GUIDELINES.md | 5 ++- config/config.exs | 8 ++++ config/runtime.exs | 18 +++++++-- docs/smtp-configuration-concept.md | 14 ++++++- lib/membership/setting.ex | 54 +++++++++---------------- lib/mv/config.ex | 38 ++++++++++++++--- lib/mv/mailer.ex | 13 ++++-- lib/mv_web/live/global_settings_live.ex | 20 ++++----- 8 files changed, 108 insertions(+), 62 deletions(-) diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 7dfa3ef..0cb8d65 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -1274,15 +1274,16 @@ mix hex.outdated **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). +- **Sensitive settings in DB:** `smtp_password` and `oidc_client_secret` are excluded from the default read of the Setting resource; they are loaded only via explicit select when needed (e.g. `Mv.Config.smtp_password/0`, `Mv.Config.oidc_client_secret/0`). This avoids exposing secrets through `get_settings()`. - 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. +- **TLS in OTP 27:** Verify mode defaults to `verify_none` for self-signed/internal certs. Set `SMTP_VERIFY_PEER=true` (or `1`/`yes`) in prod when using public SMTP (Gmail, Mailgun). Config key `:smtp_verify_peer` is set in `runtime.exs` and read by `Mv.Mailer.smtp_config/0`. - **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. +- **Production warning:** When SMTP is not configured in production, a warning is shown in the Settings UI. Use `Application.get_env(:mv, :environment, :dev)` (or assign in mount) for environment checks in LiveView/templates; do not use `Mix.env()` at runtime (it is not available in releases). - 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:** diff --git a/config/config.exs b/config/config.exs index ab55f2a..35e4160 100644 --- a/config/config.exs +++ b/config/config.exs @@ -51,6 +51,10 @@ config :mv, generators: [timestamp_type: :utc_datetime], ash_domains: [Mv.Membership, Mv.Accounts, Mv.MembershipFees, Mv.Authorization] +# Environment (dev/test/prod). Use this instead of Mix.env() at runtime; Mix.env() is +# not available in releases. Set once at compile time via config_env(). +config :mv, :environment, config_env() + # CSV Import configuration config :mv, csv_import: [ @@ -89,6 +93,10 @@ config :mv, MvWeb.Endpoint, # at the `config/runtime.exs`. config :mv, Mv.Mailer, adapter: Swoosh.Adapters.Local +# SMTP TLS verification: false = allow self-signed/internal certs; true = verify_peer (use for public SMTP). +# Overridden in runtime.exs from SMTP_VERIFY_PEER when SMTP is configured via ENV in prod. +config :mv, :smtp_verify_peer, false + # Default mail "from" address for transactional emails (join confirmation, # user confirmation, password reset). Override in config/runtime.exs from ENV. config :mv, :mail_from, {"Mila", "noreply@example.com"} diff --git a/config/runtime.exs b/config/runtime.exs index b522426..1c55f64 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -227,6 +227,10 @@ if config_env() == :prod do # 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. + # + # TLS/SSL options (tls_options, sockopts) are duplicated here and in Mv.Mailer.smtp_config/0 + # because boot config must be set in this file; the Mailer uses the same logic for + # Settings-only config. Keep verify behaviour in sync (see SMTP_VERIFY_PEER below). smtp_host_env = System.get_env("SMTP_HOST") if smtp_host_env && String.trim(smtp_host_env) != "" do @@ -250,6 +254,15 @@ if config_env() == :prod do smtp_ssl_mode = System.get_env("SMTP_SSL", "tls") + # SMTP_VERIFY_PEER: set to true/1/yes to enable TLS certificate verification (recommended + # for public SMTP like Gmail/Mailgun). Default false for self-signed/internal certs. + smtp_verify_peer = + (System.get_env("SMTP_VERIFY_PEER", "false") |> String.downcase()) in ~w(true 1 yes) + + config :mv, :smtp_verify_peer, smtp_verify_peer + + verify_mode = if smtp_verify_peer, do: :verify_peer, else: :verify_none + smtp_opts = [ adapter: Swoosh.Adapters.SMTP, @@ -260,10 +273,9 @@ if config_env() == :prod do 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] + tls_options: [verify: verify_mode], + sockopts: [verify: verify_mode] ] |> Enum.reject(fn {_k, v} -> is_nil(v) end) diff --git a/docs/smtp-configuration-concept.md b/docs/smtp-configuration-concept.md index 75e3e85..30fd7de 100644 --- a/docs/smtp-configuration-concept.md +++ b/docs/smtp-configuration-concept.md @@ -92,9 +92,12 @@ Both `SendPasswordResetEmail` and `SendNewUserConfirmationEmail` use `Mv.Mailer. 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. +By default, TLS certificate verification is relaxed (`verify_none`) so self-signed or internal SMTP servers work. For public SMTP providers (Gmail, Mailgun, etc.) you can enable verification: -For ENV-based boot config, the same options are set in `config/runtime.exs`. +- **ENV (prod):** Set `SMTP_VERIFY_PEER=true` (or `1`/`yes`) when configuring SMTP via environment variables in `config/runtime.exs`. This sets `config :mv, :smtp_verify_peer` and is used for both boot-time and per-send config. +- **Default:** `false` (verify_none) for backward compatibility and internal/self-signed certs. + +Both `tls_options` (STARTTLS, port 587) and `sockopts` (direct SSL, port 465) use the same verify mode. The logic is duplicated in `config/runtime.exs` (boot) and `Mv.Mailer.smtp_config/0` (Settings-only); keep in sync. --- @@ -112,3 +115,10 @@ For ENV-based boot config, the same options are set in `config/runtime.exs`. - [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. + +--- + +## 12. Follow-up / Future Work + +- **SMTP password at-rest encryption:** The `smtp_password` attribute is currently stored in plaintext in the `settings` table. It is excluded from default reads (same pattern as `oidc_client_secret`); both are read only via explicit select when needed. For production systems at-rest encryption (e.g. with [Cloak](https://hexdocs.pm/cloak)) should be considered and tracked as a follow-up issue. +- **Error classification:** SMTP error categorization currently uses substring matching on server messages (e.g. "535", "authentication"). A more robust approach would be to pattern-match on `gen_smtp` error tuples first where possible, and fall back to string analysis only when needed. Server wording varies; consider extending patterns as new providers are used. diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index 827e194..ce63589 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -56,6 +56,9 @@ defmodule Mv.Membership.Setting do # Update membership fee settings {:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: false}) """ + # primary_read_warning?: false — We use a custom read prepare that selects only public + # attributes and explicitly excludes smtp_password. Ash warns when the primary read does + # not load all attributes; we intentionally omit the password for security. use Ash.Resource, domain: Mv.Membership, data_layer: AshPostgres.DataLayer, @@ -65,6 +68,8 @@ defmodule Mv.Membership.Setting 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 @valid_join_form_member_fields Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) + alias Ash.Resource.Info, as: ResourceInfo + postgres do table "settings" repo Mv.Repo @@ -74,48 +79,25 @@ 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 - ] + # Attributes excluded from the default read (sensitive data). Same pattern as smtp_password: + # read only via explicit select when needed; never loaded into default get_settings(). + @excluded_from_read [:smtp_password, :oidc_client_secret] actions do 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. + # Exclude sensitive attributes (e.g. smtp_password) from default reads. Config reads + # them via explicit select when needed. Uses all attribute names minus excluded so + # the list stays correct when new attributes are added to the resource. prepare fn query, _context -> - Ash.Query.select(query, @public_attributes) + select_attrs = + __MODULE__ + |> ResourceInfo.attribute_names() + |> MapSet.to_list() + |> Kernel.--(@excluded_from_read) + + Ash.Query.select(query, select_attrs) end end diff --git a/lib/mv/config.ex b/lib/mv/config.ex index b824c1d..3494937 100644 --- a/lib/mv/config.ex +++ b/lib/mv/config.ex @@ -362,26 +362,41 @@ defmodule Mv.Config do @doc """ Returns the OIDC client secret. In production, uses the value from config :mv, :oidc (set by runtime.exs from OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE). - Otherwise ENV OIDC_CLIENT_SECRET, then Settings. + Otherwise ENV OIDC_CLIENT_SECRET, then Settings (read via explicit select; not in default get_settings). """ @spec oidc_client_secret() :: String.t() | nil def oidc_client_secret do case Application.get_env(:mv, :oidc) do oidc when is_list(oidc) -> oidc_client_secret_from_config(Keyword.get(oidc, :client_secret)) - _ -> env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret) + _ -> oidc_client_secret_from_env_or_settings() end end + @doc """ + Returns whether the OIDC client secret is set in Settings (for UI badge). Does not expose the value. + """ + @spec oidc_client_secret_set?() :: boolean() + def oidc_client_secret_set? do + present?(get_oidc_client_secret_from_settings()) + end + defp oidc_client_secret_from_config(nil), - do: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret) + do: oidc_client_secret_from_env_or_settings() defp oidc_client_secret_from_config(secret) when is_binary(secret) do s = String.trim(secret) - if s != "", do: s, else: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret) + if s != "", do: s, else: oidc_client_secret_from_env_or_settings() end defp oidc_client_secret_from_config(_), - do: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret) + do: oidc_client_secret_from_env_or_settings() + + defp oidc_client_secret_from_env_or_settings do + case System.get_env("OIDC_CLIENT_SECRET") do + nil -> get_oidc_client_secret_from_settings() + value -> trim_nil(value) + end + end @doc """ Returns the OIDC admin group name (for role sync). ENV first, then Settings. @@ -638,4 +653,17 @@ defmodule Mv.Config do nil end end + + # Reads the OIDC client secret via explicit select (excluded from default read, same as smtp_password). + defp get_oidc_client_secret_from_settings do + query = Ash.Query.select(Mv.Membership.Setting, [:id, :oidc_client_secret]) + + case Ash.read_one(query, authorize?: false, domain: Mv.Membership) do + {:ok, settings} when not is_nil(settings) -> + settings |> Map.get(:oidc_client_secret) |> trim_nil() + + _ -> + nil + end + end end diff --git a/lib/mv/mailer.ex b/lib/mv/mailer.ex index 8fca77b..e5ac4e9 100644 --- a/lib/mv/mailer.ex +++ b/lib/mv/mailer.ex @@ -33,6 +33,7 @@ defmodule Mv.Mailer do require Logger + # Simple format check for test-email recipient only (e.g. allows a@b.c). Not for strict RFC validation. @email_regex ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/ @doc """ @@ -105,6 +106,11 @@ defmodule Mv.Mailer do password = Mv.Config.smtp_password() ssl_mode = Mv.Config.smtp_ssl() || "tls" + verify_mode = + if Application.get_env(:mv, :smtp_verify_peer, false), + do: :verify_peer, + else: :verify_none + [ adapter: Swoosh.Adapters.SMTP, relay: host, @@ -114,10 +120,9 @@ defmodule Mv.Mailer do 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] + # tls_options: STARTTLS (587); sockopts: direct SSL (465). Verify from :smtp_verify_peer (ENV SMTP_VERIFY_PEER). + tls_options: [verify: verify_mode], + sockopts: [verify: verify_mode] ] |> Enum.reject(fn {_k, v} -> is_nil(v) end) else diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 60c486d..ce3351a 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -54,11 +54,14 @@ defmodule MvWeb.GlobalSettingsLive do actor = MvWeb.LiveHelpers.current_actor(socket) custom_fields = load_custom_fields(actor) + environment = Application.get_env(:mv, :environment, :dev) + socket = socket |> assign(:page_title, gettext("Settings")) |> assign(:settings, settings) |> assign(:locale, locale) + |> assign(:environment, environment) |> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?()) |> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?()) |> assign(:vereinfacht_api_key_env_set, Mv.Config.vereinfacht_api_key_env_set?()) @@ -76,7 +79,7 @@ defmodule MvWeb.GlobalSettingsLive do |> assign(:oidc_groups_claim_env_set, Mv.Config.oidc_groups_claim_env_set?()) |> 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(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?()) |> 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?()) @@ -274,7 +277,7 @@ defmodule MvWeb.GlobalSettingsLive do

<% end %> - <%= if Mix.env() == :prod and not @smtp_configured do %> + <%= if @environment == :prod and not @smtp_configured do %>
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0 mt-0.5" /> @@ -688,13 +691,10 @@ defmodule MvWeb.GlobalSettingsLive do assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))} end - # phx-change can fire with only _target (e.g. when focusing a field); avoid FunctionClauseError - def handle_event("validate", params, socket) when is_map(params) do - setting_params = - params["setting"] || Map.get(socket.assigns.form.params || %{}, "setting") || %{} - - {:noreply, - assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))} + # phx-change can fire without "setting" (e.g. only _target when focusing). Do not validate + # with previous form params to avoid surprising behaviour; wait for the next event with setting data. + def handle_event("validate", _params, socket) do + {:noreply, socket} end @impl true @@ -777,7 +777,7 @@ defmodule MvWeb.GlobalSettingsLive do socket |> assign(:settings, fresh_settings) |> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key)) - |> assign(:oidc_client_secret_set, present?(fresh_settings.oidc_client_secret)) + |> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?()) |> assign(:oidc_configured, Mv.Config.oidc_configured?()) |> assign(:smtp_configured, Mv.Config.smtp_configured?()) |> assign(:smtp_password_set, present?(Mv.Config.smtp_password())) -- 2.47.2 From a5ce7cb9211f5e36691ee6b9cc139965f73a6a0c Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 12 Mar 2026 15:46:52 +0100 Subject: [PATCH 4/4] fix group performance test --- lib/mv_web/components/layouts.ex | 23 ++++++++++++++--------- test/mv_web/live/group_live/show_test.exs | 10 ++++------ 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex index a6d75ba..2979eb4 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -43,11 +43,11 @@ defmodule MvWeb.Layouts do slot :inner_block, required: true def app(assigns) do - club_name = get_club_name() - join_form_enabled = Mv.Membership.join_form_enabled?() + # Single get_settings() for layout; derive club_name and join_form_enabled to avoid duplicate query. + %{club_name: club_name, join_form_enabled: join_form_enabled} = get_layout_settings() - # TODO: get_join_form_enabled and unprocessed count run on every page load; consider - # loading count only on navigation or caching briefly if performance becomes an issue. + # TODO: unprocessed count runs on every page load when join form enabled; consider + # loading only on navigation or caching briefly if performance becomes an issue. unprocessed_join_requests_count = get_unprocessed_join_requests_count(assigns.current_user, join_form_enabled) @@ -129,12 +129,17 @@ defmodule MvWeb.Layouts do """ end - # Helper function to get club name from settings - # Falls back to "Mitgliederverwaltung" if settings can't be loaded - defp get_club_name do + # Single settings read for layout; returns club_name and join_form_enabled to avoid duplicate get_settings(). + defp get_layout_settings do case Mv.Membership.get_settings() do - {:ok, settings} -> settings.club_name - _ -> "Mitgliederverwaltung" + {:ok, settings} -> + %{ + club_name: settings.club_name || "Mitgliederverwaltung", + join_form_enabled: settings.join_form_enabled == true + } + + _ -> + %{club_name: "Mitgliederverwaltung", join_form_enabled: false} end end diff --git a/test/mv_web/live/group_live/show_test.exs b/test/mv_web/live/group_live/show_test.exs index 1f0f1c2..4d64739 100644 --- a/test/mv_web/live/group_live/show_test.exs +++ b/test/mv_web/live/group_live/show_test.exs @@ -251,12 +251,10 @@ defmodule MvWeb.GroupLive.ShowTest do has_element?(view, "[data-testid=group-show-members-table]", member.last_name) end) - # Verify query count is reasonable (should avoid N+1 queries) - # Expected: 1 query for group lookup + 1 query for members (with preload) + member_count aggregate - # Allow overhead for authorization, LiveView setup, and other initialization queries - # Note: member_count aggregate and authorization checks may add additional queries - assert final_count <= 20, - "Expected max 20 queries (group + members preload + member_count aggregate + LiveView setup + auth), got #{final_count}. This suggests N+1 query problem." + # Verify query count is reasonable (should avoid N+1 queries). + # Baseline: group + members preload + member_count aggregate + 1 layout get_settings + auth/role/join-count. + assert final_count <= 22, + "Expected max 22 queries (group + members preload + member_count + layout + auth), got #{final_count}. This suggests N+1 query problem." end test "slug lookup is efficient (uses unique_slug index)", %{conn: conn} do -- 2.47.2