fix: repaired smtp configuration for port 587
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Simon 2026-03-16 14:00:23 +01:00
parent 837f5fd5bf
commit e95c1d6254
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
6 changed files with 138 additions and 20 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

@ -263,20 +263,25 @@ 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
base_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,
tls_options: [verify: verify_mode]
]
# Port 465: pass verify in sockopts for initial ssl:connect. Port 587: do not (gen_tcp rejects it).
smtp_opts = smtp_opts =
[ if smtp_ssl_mode == "ssl" do
adapter: Swoosh.Adapters.SMTP, Keyword.put(base_smtp_opts, :sockopts, verify: verify_mode)
relay: String.trim(smtp_host_env), else
port: smtp_port_env, base_smtp_opts
username: System.get_env("SMTP_USERNAME"), end
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) |> Enum.reject(fn {_k, v} -> is_nil(v) end)
config :mv, Mv.Mailer, smtp_opts config :mv, Mv.Mailer, smtp_opts

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 is in `config/runtime.exs` (boot) and `Mv.Mailer.smtp_config/0` (Settings-only); keep in sync.
--- ---
@ -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

@ -111,7 +111,7 @@ defmodule Mv.Mailer do
do: :verify_peer, do: :verify_peer,
else: :verify_none else: :verify_none
[ base_opts = [
adapter: Swoosh.Adapters.SMTP, adapter: Swoosh.Adapters.SMTP,
relay: host, relay: host,
port: port, port: port,
@ -120,11 +120,20 @@ defmodule Mv.Mailer do
auth: :always, auth: :always,
username: username, username: username,
password: password, password: password,
# tls_options: STARTTLS (587); sockopts: direct SSL (465). Verify from :smtp_verify_peer (ENV SMTP_VERIFY_PEER). # tls_options: used for STARTTLS (587). For 465, gen_smtp uses sockopts for initial ssl:connect.
tls_options: [verify: verify_mode], tls_options: [verify: verify_mode]
sockopts: [verify: verify_mode]
] ]
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
# Port 465: initial connection is ssl:connect; pass verify in sockopts so server cert is not required.
# 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
opts |> Enum.reject(fn {_k, v} -> is_nil(v) end)
else else
[] []
end end

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