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

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