Fix TLS config #473

Merged
simon merged 3 commits from bugfix/fix-tls-config into main 2026-03-16 15:04:35 +01:00
11 changed files with 298 additions and 43 deletions

View file

@ -41,3 +41,15 @@ ASSOCIATION_NAME="Sportsclub XYZ"
# VEREINFACHT_API_KEY=your-api-key # VEREINFACHT_API_KEY=your-api-key
# VEREINFACHT_CLUB_ID=2 # VEREINFACHT_CLUB_ID=2
# VEREINFACHT_APP_URL=https://app.verein.visuel.dev # 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

View file

@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Fixed
- **SMTP configuration** Repaired so that both port 587 (TLS/STARTTLS) and 465 (SSL) work correctly.
## [1.1.0] - 2026-03-13 ## [1.1.0] - 2026-03-13
### Added ### Added

View file

@ -130,6 +130,8 @@ lib/
│ ├── constants.ex # Application constants (member_fields, custom_field_prefix, vereinfacht_required_member_fields) │ ├── constants.ex # Application constants (member_fields, custom_field_prefix, vereinfacht_required_member_fields)
│ ├── application.ex # OTP application │ ├── application.ex # OTP application
│ ├── mailer.ex # Email mailer │ ├── mailer.ex # Email mailer
│ ├── smtp/
│ │ └── config_builder.ex # SMTP adapter opts (TLS/sockopts); used by runtime.exs and Mailer
│ ├── release.ex # Release tasks │ ├── release.ex # Release tasks
│ ├── repo.ex # Database repository │ ├── repo.ex # Database repository
│ ├── secrets.ex # Secret management │ ├── secrets.ex # Secret management

View file

@ -226,11 +226,7 @@ if config_env() == :prod do
# SMTP configuration from environment variables (overrides base adapter in prod). # 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. # 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 # If SMTP is configured only via Settings (Admin UI), the mailer builds the config
# per-send at runtime using Mv.Config.smtp_*() helpers. # per-send at runtime using Mv.Mailer.smtp_config/0 (which uses the same Mv.Smtp.ConfigBuilder).
#
# 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") smtp_host_env = System.get_env("SMTP_HOST")
if smtp_host_env && String.trim(smtp_host_env) != "" do 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 verify_mode = if smtp_verify_peer, do: :verify_peer, else: :verify_none
smtp_opts = smtp_opts =
[ Mv.Smtp.ConfigBuilder.build_opts(
adapter: Swoosh.Adapters.SMTP, host: String.trim(smtp_host_env),
relay: String.trim(smtp_host_env),
port: smtp_port_env, port: smtp_port_env,
username: System.get_env("SMTP_USERNAME"), username: System.get_env("SMTP_USERNAME"),
password: smtp_password_env, password: smtp_password_env,
ssl: smtp_ssl_mode == "ssl", ssl_mode: smtp_ssl_mode,
tls: if(smtp_ssl_mode == "tls", do: :always, else: :never), verify_mode: verify_mode
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)
config :mv, Mv.Mailer, smtp_opts config :mv, Mv.Mailer, smtp_opts
end end

View file

@ -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. - **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. - **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] 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] 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] 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] 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] 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. - [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.

View file

@ -31,6 +31,7 @@ defmodule Mv.Mailer do
import Swoosh.Email import Swoosh.Email
use Gettext, backend: MvWeb.Gettext, otp_app: :mv use Gettext, backend: MvWeb.Gettext, otp_app: :mv
alias Mv.Smtp.ConfigBuilder
require Logger require Logger
# Simple format check for test-email recipient only (e.g. allows a@b.c). Not for strict RFC validation. # 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() @spec smtp_config() :: keyword()
def smtp_config do def smtp_config do
if Mv.Config.smtp_configured?() and not boot_smtp_configured?() 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 = verify_mode =
if Application.get_env(:mv, :smtp_verify_peer, false), if Application.get_env(:mv, :smtp_verify_peer, false),
do: :verify_peer, do: :verify_peer,
else: :verify_none else: :verify_none
[ ConfigBuilder.build_opts(
adapter: Swoosh.Adapters.SMTP, host: Mv.Config.smtp_host(),
relay: host, port: Mv.Config.smtp_port() || 587,
port: port, username: Mv.Config.smtp_username(),
ssl: ssl_mode == "ssl", password: Mv.Config.smtp_password(),
tls: if(ssl_mode == "tls", do: :always, else: :never), ssl_mode: Mv.Config.smtp_ssl() || "tls",
auth: :always, verify_mode: verify_mode
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)
else else
[] []
end end

View file

@ -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

View file

@ -3854,7 +3854,7 @@ msgstr "Wenn deaktiviert, können sich Nutzer*innen nicht über /register anmeld
#: lib/mv_web/controllers/page_controller.ex #: lib/mv_web/controllers/page_controller.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Home" msgid "Home"
msgstr "" msgstr "Startseite"
#: lib/mv_web/controllers/join_confirm_controller.ex #: lib/mv_web/controllers/join_confirm_controller.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy

View file

@ -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

View file

@ -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

View file

@ -2,6 +2,9 @@ defmodule MvWeb.JoinLiveEmailFailureTest do
@moduledoc """ @moduledoc """
When join confirmation email delivery fails, the user sees an error message When join confirmation email delivery fails, the user sees an error message
and no success copy. Uses FailingMailAdapter; async: false to avoid config races. 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 use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
@ -12,18 +15,21 @@ defmodule MvWeb.JoinLiveEmailFailureTest do
test "when confirmation email fails, user sees error flash and no success message", %{ test "when confirmation email fails, user sees error flash and no success message", %{
conn: conn conn: conn
} do } 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() enable_join_form_for_test()
saved = Application.get_env(:mv, Mv.Mailer) saved_mailer = Application.get_env(:mv, Mv.Mailer)
Application.put_env( Application.put_env(
:mv, :mv,
Mv.Mailer, Mv.Mailer,
Keyword.put(saved || [], :adapter, Mv.TestSupport.FailingMailAdapter) Keyword.put(saved_mailer || [], :adapter, Mv.TestSupport.FailingMailAdapter)
) )
on_exit(fn -> on_exit(fn ->
Application.put_env(:mv, Mv.Mailer, saved) Application.put_env(:mv, Mv.Mailer, saved_mailer)
restore_smtp_env(saved_env)
end) end)
{:ok, view, _html} = live(conn, "/join") {:ok, view, _html} = live(conn, "/join")
@ -46,13 +52,36 @@ defmodule MvWeb.JoinLiveEmailFailureTest do
refute view |> element("[data-testid='join-success-message']") |> has_element?() refute view |> element("[data-testid='join-success-message']") |> has_element?()
end 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 defp enable_join_form_for_test do
{:ok, settings} = Membership.get_settings() {:ok, settings} = Membership.get_settings()
Membership.update_settings(settings, %{ Membership.update_settings(settings, %{
join_form_enabled: true, join_form_enabled: true,
join_form_field_ids: ["email", "first_name", "last_name"], 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
end end