197 lines
6.8 KiB
Elixir
197 lines
6.8 KiB
Elixir
defmodule Mv.Mailer do
|
||
@moduledoc """
|
||
Swoosh mailer for transactional emails.
|
||
|
||
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
|
||
|
||
import Swoosh.Email
|
||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||
|
||
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
|
||
{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"
|
||
|
||
[
|
||
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
|