feat: add smtp settings
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Simon 2026-03-12 13:39:48 +01:00
parent c4135308e6
commit a4f3aa5d6f
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
23 changed files with 2424 additions and 152 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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("<p>#{body}</p>")
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

View file

@ -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
</label>
</div>
<%!-- Board approval (future feature) --%>
<div class="flex items-center gap-3 mb-6">
<input
type="checkbox"
id="join-form-board-approval-checkbox"
class="checkbox checkbox-sm"
checked={false}
disabled
aria-label={gettext("Board approval required (in development)")}
/>
<label for="join-form-board-approval-checkbox" class="text-base-content/60 font-medium">
{gettext("Board approval required (in development)")}
</label>
</div>
<div :if={@join_form_enabled}>
<%!-- Field list header + Add button (left-aligned) --%>
<h3 class="font-medium mb-3">{gettext("Fields on the join form")}</h3>
@ -269,6 +266,181 @@ defmodule MvWeb.GlobalSettingsLive do
</div>
</div>
</.form_section>
<%!-- SMTP / E-Mail Section --%>
<.form_section title={gettext("SMTP / E-Mail")}>
<%= if @smtp_env_configured do %>
<p class="text-sm text-base-content/70 mb-4">
{gettext("Some values are set via environment variables. Those fields are read-only.")}
</p>
<% end %>
<%= if Mix.env() == :prod and not @smtp_configured do %>
<div class="mb-4 flex items-start gap-2 p-3 rounded-lg border border-warning bg-warning/10 text-warning-aa text-sm">
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0 mt-0.5" />
<span>
{gettext(
"SMTP is not configured. Transactional emails (join confirmation, password reset, etc.) will not be delivered reliably."
)}
</span>
</div>
<% end %>
<.form for={@form} id="smtp-form" phx-change="validate" phx-submit="save">
<div class="grid gap-4">
<.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"
)
}
/>
<div class="form-control">
<label class="label" for={@form[:smtp_password].id}>
<span class="label-text">{gettext("Password")}</span>
<%= if @smtp_password_set do %>
<span class="label-text-alt">
<.badge variant="neutral" size="sm">{gettext("(set)")}</.badge>
</span>
<% end %>
</label>
<.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
)
)
}
/>
</div>
<.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")
}
/>
</div>
<p class="mt-2 text-sm text-base-content/60">
{gettext(
"The sender email must be owned by or authorized for the SMTP user on most servers."
)}
</p>
<.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")}
</.button>
</.form>
<%!-- Test email: use form phx-submit so the current input value is always sent (e.g. after paste without blur) --%>
<div class="mt-6">
<h3 class="font-medium mb-3">{gettext("Test email")}</h3>
<.form
for={%{}}
id="smtp-test-email-form"
data-testid="smtp-test-email-form"
phx-submit="send_smtp_test_email"
class="space-y-3"
>
<div class="flex flex-wrap items-end gap-3">
<div class="form-control">
<label class="label" for="smtp-test-to-email">
<span class="label-text">{gettext("Recipient")}</span>
</label>
<input
id="smtp-test-to-email"
type="email"
name="to_email"
data-testid="smtp-test-email-input"
value={@smtp_test_to_email}
class="input input-bordered"
placeholder="test@example.com"
phx-change="update_smtp_test_to_email"
/>
</div>
<.button
type="submit"
variant="outline"
data-testid="smtp-send-test-email"
phx-disable-with={gettext("Sending...")}
>
{gettext("Send test email")}
</.button>
</div>
</.form>
<%= if @smtp_test_result do %>
<div data-testid="smtp-test-result">
<.smtp_test_result result={@smtp_test_result} />
</div>
<% end %>
</div>
</.form_section>
<%!-- 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"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-success bg-success/10 text-success-aa text-sm">
<.icon name="hero-check-circle" class="size-5 shrink-0" />
<span>{gettext("Test email sent successfully.")}</span>
</div>
"""
end
defp smtp_test_result(%{result: {:error, :invalid_email_address}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0" />
<span>{gettext("Invalid email address. Please enter a valid recipient address.")}</span>
</div>
"""
end
defp smtp_test_result(%{result: {:error, :not_implemented}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-warning bg-warning/10 text-warning-aa text-sm">
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
<span>{gettext("SMTP is not configured. Please set at least the SMTP host.")}</span>
</div>
"""
end
defp smtp_test_result(%{result: {:error, :sender_rejected}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0" />
<span>
{gettext(
"Sender address rejected. The \"Sender email\" must be owned by or authorized for the SMTP user."
)}
</span>
</div>
"""
end
defp smtp_test_result(%{result: {:error, :auth_failed}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0" />
<span>
{gettext("Authentication failed. Please check the SMTP username and password.")}
</span>
</div>
"""
end
defp smtp_test_result(%{result: {:error, :recipient_rejected}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0" />
<span>{gettext("Recipient address rejected by the server.")}</span>
</div>
"""
end
defp smtp_test_result(%{result: {:error, :tls_failed}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0" />
<span>
{gettext(
"TLS connection failed. Check the TLS/SSL setting and port (587 for TLS, 465 for SSL)."
)}
</span>
</div>
"""
end
defp smtp_test_result(%{result: {:error, :connection_failed}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0" />
<span>
{gettext("Server unreachable. Check host and port.")}
</span>
</div>
"""
end
defp smtp_test_result(%{result: {:error, {:smtp_error, message}}} = assigns)
when is_binary(message) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0" />
<span>
{gettext("SMTP error:")} {@result |> elem(1) |> elem(1)}
</span>
</div>
"""
end
defp smtp_test_result(%{result: {:error, _reason}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0" />
<span>{gettext("Failed to send test email. Please check your SMTP configuration.")}</span>
</div>
"""
end
# ---- Join form helper functions ----
defp assign_join_form_state(socket, settings, custom_fields) do