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