fix: repaired smtp configuration for port 587
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
837f5fd5bf
commit
e95c1d6254
6 changed files with 138 additions and 20 deletions
12
.env.example
12
.env.example
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
89
test/mv/mailer_smtp_config_test.exs
Normal file
89
test/mv/mailer_smtp_config_test.exs
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue