diff --git a/.env.example b/.env.example index e24b118..661593b 100644 --- a/.env.example +++ b/.env.example @@ -41,3 +41,15 @@ ASSOCIATION_NAME="Sportsclub XYZ" # VEREINFACHT_API_KEY=your-api-key # VEREINFACHT_CLUB_ID=2 # VEREINFACHT_APP_URL=https://app.verein.visuel.dev + +# Optional: Mail / SMTP (transactional emails). If set, overrides Settings UI. +# Export current UI settings to .env: mix mv.export_smtp_to_env +# SMTP_HOST=smtp.example.com +# SMTP_PORT=587 +# SMTP_USERNAME=user +# SMTP_PASSWORD=secret +# SMTP_PASSWORD_FILE=/run/secrets/smtp_password +# SMTP_SSL=tls +# SMTP_VERIFY_PEER=false +# MAIL_FROM_EMAIL=noreply@example.com +# MAIL_FROM_NAME=Mila diff --git a/CHANGELOG.md b/CHANGELOG.md index 681169f..d39df9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **SMTP configuration** – Repaired so that both port 587 (TLS/STARTTLS) and 465 (SSL) work correctly. + ## [1.1.0] - 2026-03-13 ### Added diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 8d53484..c0cb543 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -130,6 +130,8 @@ lib/ │ ├── constants.ex # Application constants (member_fields, custom_field_prefix, vereinfacht_required_member_fields) │ ├── application.ex # OTP application │ ├── mailer.ex # Email mailer +│ ├── smtp/ +│ │ └── config_builder.ex # SMTP adapter opts (TLS/sockopts); used by runtime.exs and Mailer │ ├── release.ex # Release tasks │ ├── repo.ex # Database repository │ ├── secrets.ex # Secret management diff --git a/config/runtime.exs b/config/runtime.exs index 1c55f64..6a434fa 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -226,11 +226,7 @@ if config_env() == :prod do # 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. - # - # 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). + # per-send at runtime using Mv.Mailer.smtp_config/0 (which uses the same Mv.Smtp.ConfigBuilder). smtp_host_env = System.get_env("SMTP_HOST") if smtp_host_env && String.trim(smtp_host_env) != "" do @@ -264,20 +260,14 @@ if config_env() == :prod do verify_mode = if smtp_verify_peer, do: :verify_peer, else: :verify_none smtp_opts = - [ - adapter: Swoosh.Adapters.SMTP, - relay: String.trim(smtp_host_env), + Mv.Smtp.ConfigBuilder.build_opts( + host: 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, - # tls_options: STARTTLS (587); sockopts: direct SSL (465). - tls_options: [verify: verify_mode], - sockopts: [verify: verify_mode] - ] - |> Enum.reject(fn {_k, v} -> is_nil(v) end) + ssl_mode: smtp_ssl_mode, + verify_mode: verify_mode + ) config :mv, Mv.Mailer, smtp_opts end diff --git a/docs/smtp-configuration-concept.md b/docs/smtp-configuration-concept.md index 8832b5e..13b0d17 100644 --- a/docs/smtp-configuration-concept.md +++ b/docs/smtp-configuration-concept.md @@ -105,7 +105,7 @@ By default, TLS certificate verification is relaxed (`verify_none`) so self-sign - **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. +Verify mode is set in `tls_options` for port 587 (STARTTLS). For port 465 (implicit SSL), the initial connection is `ssl:connect`, so we also pass `sockopts: [verify: verify_mode]` so the SSL handshake uses the same mode. For 587 we must not pass `verify` in sockopts—gen_tcp is used first and rejects it (ArgumentError). The logic lives in `Mv.Smtp.ConfigBuilder.build_opts/1` (single source of truth), used by `config/runtime.exs` (boot) and `Mv.Mailer.smtp_config/0` (Settings-only). --- @@ -117,7 +117,7 @@ Both `tls_options` (STARTTLS, port 587) and `sockopts` (direct SSL, port 465) us - [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] TLS certificate validation relaxed for OTP 27 (tls_options for 587; sockopts with verify only for 465). - [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] Join confirmation email: uses `Mailer.smtp_config/0` (same as test mail); on failure returns `{:error, :email_delivery_failed}`, error shown in JoinLive, logged for admin. diff --git a/lib/mv/mailer.ex b/lib/mv/mailer.ex index e5ac4e9..41a77cd 100644 --- a/lib/mv/mailer.ex +++ b/lib/mv/mailer.ex @@ -31,6 +31,7 @@ defmodule Mv.Mailer do import Swoosh.Email use Gettext, backend: MvWeb.Gettext, otp_app: :mv + alias Mv.Smtp.ConfigBuilder require Logger # Simple format check for test-email recipient only (e.g. allows a@b.c). Not for strict RFC validation. @@ -100,31 +101,19 @@ defmodule Mv.Mailer do @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" - verify_mode = if Application.get_env(:mv, :smtp_verify_peer, false), do: :verify_peer, else: :verify_none - [ - 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, - # 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) + ConfigBuilder.build_opts( + 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", + verify_mode: verify_mode + ) else [] end diff --git a/lib/mv/smtp/config_builder.ex b/lib/mv/smtp/config_builder.ex new file mode 100644 index 0000000..5018dff --- /dev/null +++ b/lib/mv/smtp/config_builder.ex @@ -0,0 +1,58 @@ +defmodule Mv.Smtp.ConfigBuilder do + @moduledoc """ + Builds Swoosh/gen_smtp SMTP adapter options from connection parameters. + + Single source of truth for TLS/sockopts logic (port 587 vs 465): + - Port 587 (STARTTLS): `gen_tcp` is used first; `sockopts` must NOT contain `:verify`. + - Port 465 (implicit SSL): initial connection is `ssl:connect`; `sockopts` must contain `:verify`. + + Used by `config/runtime.exs` (boot-time ENV) and `Mv.Mailer.smtp_config/0` (Settings-only). + """ + + @doc """ + Builds the keyword list of Swoosh SMTP adapter options. + + Options (keyword list): + - `:host` (required) — relay hostname + - `:port` (required) — port number (e.g. 587 or 465) + - `:ssl_mode` (required) — `"tls"` or `"ssl"` + - `:verify_mode` (required) — `:verify_peer` or `:verify_none` + - `:username` (optional) + - `:password` (optional) + + Nil values are stripped from the result. + """ + @spec build_opts(keyword()) :: keyword() + def build_opts(opts) do + host = Keyword.fetch!(opts, :host) + port = Keyword.fetch!(opts, :port) + username = Keyword.get(opts, :username) + password = Keyword.get(opts, :password) + ssl_mode = Keyword.fetch!(opts, :ssl_mode) + verify_mode = Keyword.fetch!(opts, :verify_mode) + + base_opts = [ + adapter: Swoosh.Adapters.SMTP, + relay: host, + port: port, + username: username, + password: password, + ssl: ssl_mode == "ssl", + tls: if(ssl_mode == "tls", do: :always, else: :never), + auth: :always, + # tls_options: used for STARTTLS (587). For 465, gen_smtp uses sockopts for initial ssl:connect. + tls_options: [verify: verify_mode] + ] + + # Port 465: initial connection is ssl:connect; pass verify in sockopts. + # Port 587: initial connection is gen_tcp; sockopts must NOT contain verify (gen_tcp rejects it). + opts = + if ssl_mode == "ssl" do + Keyword.put(base_opts, :sockopts, verify: verify_mode) + else + base_opts + end + + Enum.reject(opts, fn {_k, v} -> is_nil(v) end) + end +end diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 078ef19..c4cacc8 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3854,7 +3854,7 @@ msgstr "Wenn deaktiviert, können sich Nutzer*innen nicht über /register anmeld #: lib/mv_web/controllers/page_controller.ex #, elixir-autogen, elixir-format msgid "Home" -msgstr "" +msgstr "Startseite" #: lib/mv_web/controllers/join_confirm_controller.ex #, elixir-autogen, elixir-format, fuzzy diff --git a/test/mv/mailer_smtp_config_test.exs b/test/mv/mailer_smtp_config_test.exs new file mode 100644 index 0000000..22325df --- /dev/null +++ b/test/mv/mailer_smtp_config_test.exs @@ -0,0 +1,89 @@ +defmodule Mv.MailerSmtpConfigTest do + @moduledoc """ + Unit tests for Mv.Mailer.smtp_config/0. + + Ensures both port 587 (STARTTLS) and 465 (implicit SSL) work: + - 587: sockopts must NOT contain :verify (gen_tcp:connect would raise ArgumentError). + - 465: sockopts MUST contain :verify so initial ssl:connect uses verify_none/verify_peer. + Uses ENV to drive config; async: false. + """ + use Mv.DataCase, async: false + + alias Mv.Mailer + + defp set_smtp_env(key, value), do: System.put_env(key, value) + + 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 + + describe "smtp_config/0" do + test "port 587 (TLS): does not include :verify in sockopts so gen_tcp:connect does not crash" do + set_smtp_env("SMTP_HOST", "smtp.example.com") + set_smtp_env("SMTP_PORT", "587") + set_smtp_env("SMTP_SSL", "tls") + + config = Mailer.smtp_config() + + assert config != [], "expected non-empty config when SMTP_HOST is set" + + sockopts = Keyword.get(config, :sockopts, []) + + refute Keyword.has_key?(sockopts, :verify), + "for 587 gen_tcp is used first; sockopts must not contain :verify" + after + clear_smtp_env() + end + + test "port 465 (SSL): includes :verify in sockopts so initial ssl:connect accepts verify mode" do + set_smtp_env("SMTP_HOST", "smtp.example.com") + set_smtp_env("SMTP_PORT", "465") + set_smtp_env("SMTP_SSL", "ssl") + + config = Mailer.smtp_config() + + assert config != [] + sockopts = Keyword.get(config, :sockopts, []) + + assert Keyword.has_key?(sockopts, :verify), + "for 465 initial connection is ssl:connect; sockopts must contain :verify" + + assert Keyword.get(sockopts, :verify) in [:verify_none, :verify_peer] + after + clear_smtp_env() + end + + test "builds TLS mode for port 587 (STARTTLS)" do + set_smtp_env("SMTP_HOST", "smtp.example.com") + set_smtp_env("SMTP_PORT", "587") + set_smtp_env("SMTP_SSL", "tls") + + config = Mailer.smtp_config() + + assert config != [] + assert Keyword.get(config, :tls) == :always + assert Keyword.get(config, :ssl) == false + after + clear_smtp_env() + end + + test "builds SSL mode for port 465 (implicit SSL)" do + set_smtp_env("SMTP_HOST", "smtp.example.com") + set_smtp_env("SMTP_PORT", "465") + set_smtp_env("SMTP_SSL", "ssl") + + config = Mailer.smtp_config() + + assert config != [] + assert Keyword.get(config, :ssl) == true + assert Keyword.get(config, :tls) == :never + after + clear_smtp_env() + end + end +end diff --git a/test/mv/smtp/config_builder_test.exs b/test/mv/smtp/config_builder_test.exs new file mode 100644 index 0000000..0b12a52 --- /dev/null +++ b/test/mv/smtp/config_builder_test.exs @@ -0,0 +1,83 @@ +defmodule Mv.Smtp.ConfigBuilderTest do + @moduledoc """ + Unit tests for Mv.Smtp.ConfigBuilder.build_opts/1. + + Ensures the single source of truth for SMTP opts correctly handles: + - Port 587 (TLS): sockopts must NOT contain :verify (gen_tcp rejects it). + - Port 465 (SSL): sockopts MUST contain :verify for initial ssl:connect. + """ + use ExUnit.Case, async: true + + alias Mv.Smtp.ConfigBuilder + + describe "build_opts/1" do + test "port 587 (TLS): does not include :verify in sockopts" do + opts = + ConfigBuilder.build_opts( + host: "smtp.example.com", + port: 587, + username: "user", + password: "secret", + ssl_mode: "tls", + verify_mode: :verify_none + ) + + sockopts = Keyword.get(opts, :sockopts, []) + refute Keyword.has_key?(sockopts, :verify), "for 587 sockopts must not contain :verify" + assert Keyword.get(opts, :tls) == :always + assert Keyword.get(opts, :ssl) == false + end + + test "port 465 (SSL): includes :verify in sockopts" do + opts = + ConfigBuilder.build_opts( + host: "smtp.example.com", + port: 465, + username: "user", + password: "secret", + ssl_mode: "ssl", + verify_mode: :verify_peer + ) + + sockopts = Keyword.get(opts, :sockopts, []) + assert Keyword.has_key?(sockopts, :verify), "for 465 sockopts must contain :verify" + assert Keyword.get(sockopts, :verify) == :verify_peer + assert Keyword.get(opts, :ssl) == true + assert Keyword.get(opts, :tls) == :never + end + + test "strips nil values" do + opts = + ConfigBuilder.build_opts( + host: "smtp.example.com", + port: 587, + username: nil, + password: nil, + ssl_mode: "tls", + verify_mode: :verify_none + ) + + refute Keyword.has_key?(opts, :username) + refute Keyword.has_key?(opts, :password) + assert Keyword.get(opts, :relay) == "smtp.example.com" + end + + test "includes adapter and required keys" do + opts = + ConfigBuilder.build_opts( + host: "mail.example.com", + port: 587, + username: "u", + password: "p", + ssl_mode: "tls", + verify_mode: :verify_none + ) + + assert Keyword.get(opts, :adapter) == Swoosh.Adapters.SMTP + assert Keyword.get(opts, :relay) == "mail.example.com" + assert Keyword.get(opts, :port) == 587 + assert Keyword.get(opts, :auth) == :always + assert Keyword.get(opts, :tls_options) == [verify: :verify_none] + end + end +end diff --git a/test/mv_web/live/join_live_email_failure_test.exs b/test/mv_web/live/join_live_email_failure_test.exs index ef308c6..50a209c 100644 --- a/test/mv_web/live/join_live_email_failure_test.exs +++ b/test/mv_web/live/join_live_email_failure_test.exs @@ -2,6 +2,9 @@ defmodule MvWeb.JoinLiveEmailFailureTest do @moduledoc """ When join confirmation email delivery fails, the user sees an error message and no success copy. Uses FailingMailAdapter; async: false to avoid config races. + + Ensures SMTP is not configured (ENV cleared, Settings smtp_host nil) so that + Mailer.smtp_config/0 returns [] and the Application-configured FailingMailAdapter is used. """ use MvWeb.ConnCase, async: false import Phoenix.LiveViewTest @@ -12,18 +15,21 @@ defmodule MvWeb.JoinLiveEmailFailureTest do test "when confirmation email fails, user sees error flash and no success message", %{ conn: conn } do + # Clear SMTP config so Mailer.smtp_config() returns [] and FailingMailAdapter is used. + saved_env = clear_smtp_env_and_save() enable_join_form_for_test() - saved = Application.get_env(:mv, Mv.Mailer) + saved_mailer = Application.get_env(:mv, Mv.Mailer) Application.put_env( :mv, Mv.Mailer, - Keyword.put(saved || [], :adapter, Mv.TestSupport.FailingMailAdapter) + Keyword.put(saved_mailer || [], :adapter, Mv.TestSupport.FailingMailAdapter) ) on_exit(fn -> - Application.put_env(:mv, Mv.Mailer, saved) + Application.put_env(:mv, Mv.Mailer, saved_mailer) + restore_smtp_env(saved_env) end) {:ok, view, _html} = live(conn, "/join") @@ -46,13 +52,36 @@ defmodule MvWeb.JoinLiveEmailFailureTest do refute view |> element("[data-testid='join-success-message']") |> has_element?() end + defp clear_smtp_env_and_save do + keys = [ + "SMTP_HOST", + "SMTP_PORT", + "SMTP_USERNAME", + "SMTP_PASSWORD", + "SMTP_PASSWORD_FILE", + "SMTP_SSL" + ] + + saved = for k <- keys, into: %{}, do: {k, System.get_env(k)} + for k <- keys, do: System.delete_env(k) + saved + end + + defp restore_smtp_env(saved) do + for {k, v} <- saved do + if v != nil, do: System.put_env(k, v), else: System.delete_env(k) + end + end + defp enable_join_form_for_test do {:ok, settings} = Membership.get_settings() Membership.update_settings(settings, %{ join_form_enabled: true, join_form_field_ids: ["email", "first_name", "last_name"], - join_form_field_required: %{"email" => true, "first_name" => false, "last_name" => false} + join_form_field_required: %{"email" => true, "first_name" => false, "last_name" => false}, + # Clear SMTP host so Mv.Config.smtp_configured?() is false and Mailer.smtp_config() returns []. + smtp_host: nil }) end end