Merge branch 'main' into feature/ux_button_concistency
This commit is contained in:
commit
3d72cb8753
29 changed files with 12039 additions and 9300 deletions
|
|
@ -56,14 +56,20 @@ defmodule Mv.Membership.Setting do
|
|||
# Update membership fee settings
|
||||
{:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: false})
|
||||
"""
|
||||
# primary_read_warning?: false — We use a custom read prepare that selects only public
|
||||
# attributes and explicitly excludes smtp_password. Ash warns when the primary read does
|
||||
# not load all attributes; we intentionally omit the password for security.
|
||||
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
|
||||
@valid_join_form_member_fields Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
|
||||
alias Ash.Resource.Info, as: ResourceInfo
|
||||
|
||||
postgres do
|
||||
table "settings"
|
||||
repo Mv.Repo
|
||||
|
|
@ -73,8 +79,27 @@ defmodule Mv.Membership.Setting do
|
|||
description "Global application settings (singleton resource)"
|
||||
end
|
||||
|
||||
# Attributes excluded from the default read (sensitive data). Same pattern as smtp_password:
|
||||
# read only via explicit select when needed; never loaded into default get_settings().
|
||||
@excluded_from_read [:smtp_password, :oidc_client_secret]
|
||||
|
||||
actions do
|
||||
defaults [:read]
|
||||
read :read do
|
||||
primary? true
|
||||
|
||||
# Exclude sensitive attributes (e.g. smtp_password) from default reads. Config reads
|
||||
# them via explicit select when needed. Uses all attribute names minus excluded so
|
||||
# the list stays correct when new attributes are added to the resource.
|
||||
prepare fn query, _context ->
|
||||
select_attrs =
|
||||
__MODULE__
|
||||
|> ResourceInfo.attribute_names()
|
||||
|> MapSet.to_list()
|
||||
|> Kernel.--(@excluded_from_read)
|
||||
|
||||
Ash.Query.select(query, select_attrs)
|
||||
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 +122,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 +158,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 +468,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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
227
lib/mv/config.ex
227
lib/mv/config.ex
|
|
@ -362,26 +362,41 @@ defmodule Mv.Config do
|
|||
@doc """
|
||||
Returns the OIDC client secret.
|
||||
In production, uses the value from config :mv, :oidc (set by runtime.exs from OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE).
|
||||
Otherwise ENV OIDC_CLIENT_SECRET, then Settings.
|
||||
Otherwise ENV OIDC_CLIENT_SECRET, then Settings (read via explicit select; not in default get_settings).
|
||||
"""
|
||||
@spec oidc_client_secret() :: String.t() | nil
|
||||
def oidc_client_secret do
|
||||
case Application.get_env(:mv, :oidc) do
|
||||
oidc when is_list(oidc) -> oidc_client_secret_from_config(Keyword.get(oidc, :client_secret))
|
||||
_ -> env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
|
||||
_ -> oidc_client_secret_from_env_or_settings()
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns whether the OIDC client secret is set in Settings (for UI badge). Does not expose the value.
|
||||
"""
|
||||
@spec oidc_client_secret_set?() :: boolean()
|
||||
def oidc_client_secret_set? do
|
||||
present?(get_oidc_client_secret_from_settings())
|
||||
end
|
||||
|
||||
defp oidc_client_secret_from_config(nil),
|
||||
do: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
|
||||
do: oidc_client_secret_from_env_or_settings()
|
||||
|
||||
defp oidc_client_secret_from_config(secret) when is_binary(secret) do
|
||||
s = String.trim(secret)
|
||||
if s != "", do: s, else: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
|
||||
if s != "", do: s, else: oidc_client_secret_from_env_or_settings()
|
||||
end
|
||||
|
||||
defp oidc_client_secret_from_config(_),
|
||||
do: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
|
||||
do: oidc_client_secret_from_env_or_settings()
|
||||
|
||||
defp oidc_client_secret_from_env_or_settings do
|
||||
case System.get_env("OIDC_CLIENT_SECRET") do
|
||||
nil -> get_oidc_client_secret_from_settings()
|
||||
value -> trim_nil(value)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the OIDC admin group name (for role sync). ENV first, then Settings.
|
||||
|
|
@ -449,4 +464,206 @@ defmodule Mv.Config do
|
|||
def oidc_admin_group_name_env_set?, do: env_set?("OIDC_ADMIN_GROUP_NAME")
|
||||
def oidc_groups_claim_env_set?, do: env_set?("OIDC_GROUPS_CLAIM")
|
||||
def oidc_only_env_set?, do: env_set?("OIDC_ONLY")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SMTP configuration – ENV overrides Settings; see docs/smtp-configuration-concept.md
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
Returns SMTP host. ENV `SMTP_HOST` overrides Settings.
|
||||
"""
|
||||
@spec smtp_host() :: String.t() | nil
|
||||
def smtp_host do
|
||||
smtp_env_or_setting("SMTP_HOST", :smtp_host)
|
||||
end
|
||||
|
||||
@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
|
||||
case System.get_env("SMTP_PORT") do
|
||||
nil ->
|
||||
get_from_settings_integer(:smtp_port)
|
||||
|
||||
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
|
||||
smtp_env_or_setting("SMTP_USERNAME", :smtp_username)
|
||||
end
|
||||
|
||||
@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
|
||||
case System.get_env("SMTP_PASSWORD") do
|
||||
nil -> smtp_password_from_file_or_settings()
|
||||
value -> trim_nil(value)
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
smtp_env_or_setting("SMTP_SSL", :smtp_ssl)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true when SMTP is configured (host present from ENV or Settings).
|
||||
"""
|
||||
@spec smtp_configured?() :: boolean()
|
||||
def smtp_configured? do
|
||||
present?(smtp_host())
|
||||
end
|
||||
|
||||
@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
|
||||
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
|
||||
|
||||
# Reads the OIDC client secret via explicit select (excluded from default read, same as smtp_password).
|
||||
defp get_oidc_client_secret_from_settings do
|
||||
query = Ash.Query.select(Mv.Membership.Setting, [:id, :oidc_client_secret])
|
||||
|
||||
case Ash.read_one(query, authorize?: false, domain: Mv.Membership) do
|
||||
{:ok, settings} when not is_nil(settings) ->
|
||||
settings |> Map.get(:oidc_client_secret) |> trim_nil()
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
193
lib/mv/mailer.ex
193
lib/mv/mailer.ex
|
|
@ -4,16 +4,199 @@ 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
|
||||
|
||||
# Simple format check for test-email recipient only (e.g. allows a@b.c). Not for strict RFC validation.
|
||||
@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.
|
||||
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, 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"
|
||||
|
||||
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)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -43,11 +43,11 @@ defmodule MvWeb.Layouts do
|
|||
slot :inner_block, required: true
|
||||
|
||||
def app(assigns) do
|
||||
club_name = get_club_name()
|
||||
join_form_enabled = Mv.Membership.join_form_enabled?()
|
||||
# Single get_settings() for layout; derive club_name and join_form_enabled to avoid duplicate query.
|
||||
%{club_name: club_name, join_form_enabled: join_form_enabled} = get_layout_settings()
|
||||
|
||||
# TODO: get_join_form_enabled and unprocessed count run on every page load; consider
|
||||
# loading count only on navigation or caching briefly if performance becomes an issue.
|
||||
# TODO: unprocessed count runs on every page load when join form enabled; consider
|
||||
# loading only on navigation or caching briefly if performance becomes an issue.
|
||||
unprocessed_join_requests_count =
|
||||
get_unprocessed_join_requests_count(assigns.current_user, join_form_enabled)
|
||||
|
||||
|
|
@ -129,12 +129,17 @@ defmodule MvWeb.Layouts do
|
|||
"""
|
||||
end
|
||||
|
||||
# Helper function to get club name from settings
|
||||
# Falls back to "Mitgliederverwaltung" if settings can't be loaded
|
||||
defp get_club_name do
|
||||
# Single settings read for layout; returns club_name and join_form_enabled to avoid duplicate get_settings().
|
||||
defp get_layout_settings do
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, settings} -> settings.club_name
|
||||
_ -> "Mitgliederverwaltung"
|
||||
{:ok, settings} ->
|
||||
%{
|
||||
club_name: settings.club_name || "Mitgliederverwaltung",
|
||||
join_form_enabled: settings.join_form_enabled == true
|
||||
}
|
||||
|
||||
_ ->
|
||||
%{club_name: "Mitgliederverwaltung", join_form_enabled: false}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -54,11 +54,14 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||
custom_fields = load_custom_fields(actor)
|
||||
|
||||
environment = Application.get_env(:mv, :environment, :dev)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, gettext("Settings"))
|
||||
|> assign(:settings, settings)
|
||||
|> assign(:locale, locale)
|
||||
|> assign(:environment, environment)
|
||||
|> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?())
|
||||
|> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?())
|
||||
|> assign(:vereinfacht_api_key_env_set, Mv.Config.vereinfacht_api_key_env_set?())
|
||||
|
|
@ -76,7 +79,19 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|> assign(:oidc_groups_claim_env_set, Mv.Config.oidc_groups_claim_env_set?())
|
||||
|> 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(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|
||||
|> 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 +152,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 +269,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 @environment == :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("Accounting-Software (Vereinfacht) Integration")}>
|
||||
<%= if @vereinfacht_env_configured do %>
|
||||
|
|
@ -516,6 +691,27 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
|
||||
end
|
||||
|
||||
# phx-change can fire without "setting" (e.g. only _target when focusing). Do not validate
|
||||
# with previous form params to avoid surprising behaviour; wait for the next event with setting data.
|
||||
def handle_event("validate", _params, socket) do
|
||||
{:noreply, socket}
|
||||
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)
|
||||
|
||||
|
|
@ -579,8 +777,12 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
socket
|
||||
|> assign(:settings, fresh_settings)
|
||||
|> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key))
|
||||
|> assign(:oidc_client_secret_set, present?(fresh_settings.oidc_client_secret))
|
||||
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|
||||
|> 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue