feat: add smtp settings
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
c4135308e6
commit
a4f3aa5d6f
23 changed files with 2424 additions and 152 deletions
187
lib/mv/mailer.ex
187
lib/mv/mailer.ex
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue