diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md
index 6f8deb5..e1dfc75 100644
--- a/CODE_GUIDELINES.md
+++ b/CODE_GUIDELINES.md
@@ -1267,7 +1267,27 @@ mix hex.outdated
**Mailer and from address:**
- `Mv.Mailer` (Swoosh) and `Mv.Mailer.mail_from/0` return the configured sender `{name, email}`.
-- Config: `config :mv, :mail_from, {"Mila", "noreply@example.com"}` in config.exs. In production, runtime.exs overrides from ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`).
+- Sender identity priority: `MAIL_FROM_NAME`/`MAIL_FROM_EMAIL` ENV > Settings `smtp_from_name`/`smtp_from_email` > hardcoded defaults `{"Mila", "noreply@example.com"}`.
+- Access via `Mv.Config.mail_from_name/0` and `Mv.Config.mail_from_email/0`.
+- **Important:** On most SMTP servers the sender email must be the same address as `smtp_username` or an alias owned by that account (e.g. Postfix strict relay). Misconfiguration causes a 553 error.
+
+**SMTP configuration:**
+
+- SMTP can be configured via **ENV variables** (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) or via **Admin Settings** (database: `smtp_host`, `smtp_port`, `smtp_username`, `smtp_password`, `smtp_ssl`). ENV takes priority (same pattern as OIDC/Vereinfacht).
+- Sender identity is also configurable via ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`) or Settings (`smtp_from_name`, `smtp_from_email`).
+- `SMTP_PASSWORD_FILE`: path to a file containing the password (Docker Secrets / Kubernetes secrets pattern); overridden by `SMTP_PASSWORD` when both are set.
+- `SMTP_SSL` values: `tls` (default, port 587), `ssl` (port 465), `none` (port 25).
+- When `SMTP_HOST` ENV is present at boot, `runtime.exs` configures `Swoosh.Adapters.SMTP` automatically.
+- When SMTP is configured only via Settings, `Mv.Mailer.smtp_config/0` builds the adapter config per-send.
+- In test environment, `Swoosh.Adapters.Test` is used regardless of SMTP config.
+- **TLS in OTP 27:** `tls_options: [verify: :verify_none]` (STARTTLS/587) and `sockopts: [verify: :verify_none]` (SSL/465) are set to allow self-signed / internal certs.
+- **Test email:** `Mv.Mailer.send_test_email(to_email)` sends a transactional test email; returns `{:ok, email}` or `{:error, classified_reason}`. Classified errors: `:sender_rejected`, `:auth_failed`, `:recipient_rejected`, `:tls_failed`, `:connection_failed`, `{:smtp_error, message}`. Each shows a specific message in the UI.
+- **Production warning:** When SMTP is not configured in production, a warning is shown in the Settings UI.
+- Access config values via `Mv.Config.smtp_host/0`, `smtp_port/0`, `smtp_username/0`, `smtp_password/0`, `smtp_ssl/0`, `smtp_configured?/0`.
+
+**AshAuthentication senders:**
+
+- `SendPasswordResetEmail` and `SendNewUserConfirmationEmail` use `Mv.Mailer.deliver/1` (not `deliver!/1`). Errors are logged via `Logger.error` and not re-raised so they never crash the caller process.
**Unified layout (transactional emails):**
@@ -1287,7 +1307,11 @@ new()
|> put_view(MvWeb.EmailsView)
|> put_layout({MvWeb.EmailLayoutView, "layout.html"})
|> render_body("template_name.html", %{assigns})
-|> Mailer.deliver!()
+
+case Mailer.deliver(email) do
+ {:ok, _} -> :ok
+ {:error, reason} -> Logger.error("Email delivery failed: #{inspect(reason)}")
+end
```
### 3.12 Internationalization: Gettext
diff --git a/config/runtime.exs b/config/runtime.exs
index b8570d8..b522426 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -223,19 +223,50 @@ if config_env() == :prod do
{System.get_env("MAIL_FROM_NAME", "Mila"),
System.get_env("MAIL_FROM_EMAIL", "noreply@example.com")}
- # In production you may need to configure the mailer to use a different adapter.
- # Also, you may need to configure the Swoosh API client of your choice if you
- # are not using SMTP. Here is an example of the configuration:
- #
- # config :mv, Mv.Mailer,
- # adapter: Swoosh.Adapters.Mailgun,
- # api_key: System.get_env("MAILGUN_API_KEY"),
- # domain: System.get_env("MAILGUN_DOMAIN")
- #
- # For this example you need include a HTTP client required by Swoosh API client.
- # Swoosh supports Hackney, Req and Finch out of the box:
- #
- # config :swoosh, :api_client, Swoosh.ApiClient.Hackney
- #
- # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
+ # SMTP configuration from environment variables (overrides base adapter in prod).
+ # When SMTP_HOST is set, configure Swoosh to use the SMTP adapter at boot time.
+ # If SMTP is configured only via Settings (Admin UI), the mailer builds the config
+ # per-send at runtime using Mv.Config.smtp_*() helpers.
+ smtp_host_env = System.get_env("SMTP_HOST")
+
+ if smtp_host_env && String.trim(smtp_host_env) != "" do
+ smtp_port_env =
+ case System.get_env("SMTP_PORT") do
+ nil -> 587
+ v -> String.to_integer(String.trim(v))
+ end
+
+ smtp_password_env =
+ case System.get_env("SMTP_PASSWORD") do
+ nil ->
+ case System.get_env("SMTP_PASSWORD_FILE") do
+ nil -> nil
+ path -> path |> File.read!() |> String.trim()
+ end
+
+ v ->
+ v
+ end
+
+ smtp_ssl_mode = System.get_env("SMTP_SSL", "tls")
+
+ smtp_opts =
+ [
+ adapter: Swoosh.Adapters.SMTP,
+ relay: String.trim(smtp_host_env),
+ port: smtp_port_env,
+ username: System.get_env("SMTP_USERNAME"),
+ password: smtp_password_env,
+ ssl: smtp_ssl_mode == "ssl",
+ tls: if(smtp_ssl_mode == "tls", do: :always, else: :never),
+ auth: :always,
+ # Allow self-signed or internal SMTP server certs (OTP 26+ enforces verify_peer with cacerts).
+ # tls_options: STARTTLS (587); sockopts: direct SSL (465).
+ tls_options: [verify: :verify_none],
+ sockopts: [verify: :verify_none]
+ ]
+ |> Enum.reject(fn {_k, v} -> is_nil(v) end)
+
+ config :mv, Mv.Mailer, smtp_opts
+ end
end
diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md
index f3b1e27..c74f064 100644
--- a/docs/feature-roadmap.md
+++ b/docs/feature-roadmap.md
@@ -270,8 +270,10 @@
**Open Issues:**
- [#186](https://git.local-it.org/local-it/mitgliederverwaltung/issues/186) - Create Architecture docs in Repo (S, Low priority)
+**Implemented Features:**
+- ✅ **SMTP configuration** – Configure mail server via ENV (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) and Admin Settings (UI), with ENV taking priority. Test email from Settings SMTP section. Production warning when SMTP is not configured. See [`docs/smtp-configuration-concept.md`](smtp-configuration-concept.md).
+
**Missing Features:**
-- ❌ **SMTP configuration** – Configure mail server via ENV and Admin Settings, test email from Settings. See [`docs/smtp-configuration-concept.md`](smtp-configuration-concept.md).
- ❌ Email templates configuration
- ❌ System health dashboard
- ❌ Audit log viewer
@@ -288,7 +290,7 @@
- ✅ Swoosh mailer integration
- ✅ Email confirmation (via AshAuthentication)
- ✅ Password reset emails (via AshAuthentication)
-- ⚠️ No SMTP configuration (mailer uses Local/Test adapter; prod not configured)
+- ✅ **SMTP configuration** via ENV and Admin Settings (see Admin Panel section)
- ⚠️ No member communication features
**Missing Features:**
diff --git a/docs/smtp-configuration-concept.md b/docs/smtp-configuration-concept.md
index b0ca8cc..75e3e85 100644
--- a/docs/smtp-configuration-concept.md
+++ b/docs/smtp-configuration-concept.md
@@ -1,7 +1,7 @@
# SMTP Configuration – Concept
-**Status:** Draft
-**Last updated:** 2026-03-11
+**Status:** Implemented
+**Last updated:** 2026-03-12
---
@@ -13,8 +13,8 @@ Enable configurable SMTP for sending transactional emails (join confirmation, us
## 2. Scope
-- **In scope:** SMTP server configuration (host, port, credentials, TLS/SSL), test email from Settings UI, warning when SMTP is not configured in production.
-- **Out of scope:** Changing how AshAuthentication or existing senders use the mailer; they keep using `Mv.Mailer` and `mail_from/0`. No separate "form_mail" config – the existing **mail_from** (MAIL_FROM_NAME, MAIL_FROM_EMAIL) remains the single sender identity for all transactional mails.
+- **In scope:** SMTP server configuration (host, port, credentials, TLS/SSL), sender identity (from-name, from-email), test email from Settings UI, warning when SMTP is not configured in production, specific error messages per failure category, graceful delivery errors in AshAuthentication senders.
+- **Out of scope:** Separate adapters per email type; retry queues.
---
@@ -31,71 +31,84 @@ When an ENV variable is set, the corresponding Settings field is read-only in th
## 4. SMTP Parameters
-| Parameter | ENV | Settings attribute | Notes |
-|------------|------------------------|--------------------|--------------------------------------------|
-| Host | `SMTP_HOST` | `smtp_host` | e.g. `smtp.example.com` |
-| Port | `SMTP_PORT` | `smtp_port` | Default 587 (TLS), 465 (SSL), 25 (plain) |
-| Username | `SMTP_USERNAME` | `smtp_username` | Optional if no auth |
-| Password | `SMTP_PASSWORD` | `smtp_password` | Sensitive, not shown when set |
-| Password | `SMTP_PASSWORD_FILE` | — | Docker/Secrets: path to file with password |
-| TLS/SSL | `SMTP_SSL` or similar | `smtp_ssl` | e.g. `tls` / `ssl` / `none` (default: tls)|
+| Parameter | ENV | Settings attribute | Notes |
+|----------------|------------------------|---------------------|---------------------------------------------|
+| Host | `SMTP_HOST` | `smtp_host` | e.g. `smtp.example.com` |
+| Port | `SMTP_PORT` | `smtp_port` | Default 587 (TLS), 465 (SSL), 25 (plain) |
+| Username | `SMTP_USERNAME` | `smtp_username` | Optional if no auth |
+| Password | `SMTP_PASSWORD` | `smtp_password` | Sensitive, not shown when set |
+| Password file | `SMTP_PASSWORD_FILE` | — | Docker/Secrets: path to file with password |
+| TLS/SSL | `SMTP_SSL` | `smtp_ssl` | `tls` / `ssl` / `none` (default: tls) |
+| Sender name | `MAIL_FROM_NAME` | `smtp_from_name` | Display name in "From" header (default: Mila)|
+| Sender email | `MAIL_FROM_EMAIL` | `smtp_from_email` | Address in "From" header; must match SMTP user on most servers |
-**Sender (unchanged):** `mail_from` stays separate (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL` in ENV; no DB fields for from-address).
+**Important:** On most SMTP servers (e.g. Postfix with strict relay policies) the sender email (`smtp_from_email`) must be the same address as `smtp_username` or an alias that is owned by that account.
---
## 5. Password from File
-Support **SMTP_PASSWORD_FILE** (path to file containing the password), same pattern as `OIDC_CLIENT_SECRET_FILE` and `TOKEN_SIGNING_SECRET_FILE` in `runtime.exs`. Read once at runtime when building mailer config; ENV `SMTP_PASSWORD` overrides file if both are set (or define explicit precedence and document it).
+Support **SMTP_PASSWORD_FILE** (path to file containing the password), same pattern as `OIDC_CLIENT_SECRET_FILE` in `runtime.exs`. Read once at runtime; `SMTP_PASSWORD` ENV overrides file if both are set.
---
## 6. Behaviour When SMTP Is Not Configured
- **Dev/Test:** Keep current adapters (`Swoosh.Adapters.Local`, `Swoosh.Adapters.Test`). No change.
-- **Production:** If neither ENV nor Settings provide SMTP (e.g. no host):
- - Keep using the default adapter (e.g. Local) or a no-op adapter so the app does not crash.
- - **Show a clear warning in the Settings UI** (SMTP section): e.g. "SMTP is not configured. Transactional emails (join confirmation, password reset, etc.) will not be delivered reliably." and optionally list consequences (no join confirmations, no password resets, etc.).
- - Log a warning at startup or when sending is attempted if SMTP is not configured in prod.
+- **Production:** If neither ENV nor Settings provide SMTP (no host):
+ - Show a warning in the Settings UI.
+ - Delivery attempts silently fall back to the Local adapter (no crash).
---
## 7. Test Email (Settings UI)
-- **Location:** SMTP / E-Mail section in Global Settings (same page as OIDC, Vereinfacht).
-- **Elements:**
- - Input: **recipient email address** (required for sending).
- - Button: **"Send test email"** (or similar).
-- **Behaviour:** On click, send one simple transactional-style email to the given address (subject and body translatable via Gettext, e.g. "Mila – Test email" / "This is a test."). Use current SMTP config and `mail_from`.
-- **Feedback:** Show success message or error (e.g. connection refused, auth failed, invalid address). Reuse the same UI pattern as Vereinfacht "Test Integration" (result assign, small result component with success/error states).
-- **Permission:** Reuse existing Settings page authorization (admin); no extra check for the test-email action.
+- **Location:** SMTP / E-Mail section in Global Settings.
+- **Elements:** Input for recipient, submit button inside a `phx-submit` form.
+- **Behaviour:** Sends one email using current SMTP config and `mail_from/0`. Returns `{:ok, _}` or `{:error, classified_reason}`.
+- **Error categories:** `:sender_rejected`, `:auth_failed`, `:recipient_rejected`, `:tls_failed`, `:connection_failed`, `{:smtp_error, message}` — each shows a specific human-readable message in the UI.
+- **Permission:** Reuses existing Settings page authorization (admin).
---
-## 8. Implementation Hints
+## 8. Sender Identity (`mail_from`)
-- **Config module:** Extend `Mv.Config` with `smtp_*` helpers (e.g. `smtp_host/0`, `smtp_port/0`, …) using `env_or_setting/2` and, for password, ENV vs `SMTP_PASSWORD_FILE` vs Settings (sensitive).
-- **runtime.exs:** When SMTP is configured (e.g. host present), set `config :mv, Mv.Mailer, adapter: Swoosh.Adapters.SMTP, ...` with the merged options. Otherwise leave adapter as in base config (Local in dev, Test in test, and in prod either Local with warning or explicit "not configured" behaviour).
-- **Setting resource:** New attributes: `smtp_host`, `smtp_port`, `smtp_username`, `smtp_password` (sensitive), `smtp_ssl` (string or enum). Add to create/update `accept` lists and to seeds if needed.
-- **Migration:** Add columns for the new Setting attributes.
-- **Test email:** New function (e.g. `Mv.Mailer.send_test_email(to_email)`) returning `{:ok, _}` or `{:error, reason}`; call from LiveView event and render result in the SMTP section.
+`Mv.Mailer.mail_from/0` returns `{name, email}`. Priority:
+1. `MAIL_FROM_NAME` / `MAIL_FROM_EMAIL` ENV variables
+2. `smtp_from_name` / `smtp_from_email` in Settings (DB)
+3. Hardcoded defaults: `{"Mila", "noreply@example.com"}`
+
+Provided by `Mv.Config.mail_from_name/0` and `Mv.Config.mail_from_email/0`.
---
-## 9. Documentation and i18n
+## 9. AshAuthentication Senders
-- **Gettext:** Use Gettext for test email subject and body and for all new Settings labels/hints (including the "SMTP not configured" warning).
-- **Docs:** Update `CODE_GUIDELINES.md` (e.g. §3.11 Email) and deployment/configuration docs to describe ENV and Settings for SMTP and the test email. Add this feature to `docs/feature-roadmap.md` (e.g. under Admin Panel & Configuration or Communication).
+Both `SendPasswordResetEmail` and `SendNewUserConfirmationEmail` use `Mv.Mailer.deliver/1` (not `deliver!/1`). Delivery failures are logged (`Logger.error`) and not re-raised, so they never crash the caller process. AshAuthentication ignores the return value of `send/3`.
---
-## 10. Summary Checklist
+## 10. TLS / SSL in OTP 27
-- [ ] ENV: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL` (or equivalent).
-- [ ] Settings: attributes and UI for host, port, username, password, TLS/SSL; ENV-override hints.
-- [ ] Password from file: `SMTP_PASSWORD_FILE` supported in runtime config.
-- [ ] Mailer: Swoosh SMTP adapter configured from merged ENV + Settings when SMTP is configured.
-- [ ] Prod warning: clear message in Settings when SMTP is not configured, with consequences.
-- [ ] Test email: button + recipient field, translatable content, success/error display; existing permission sufficient.
-- [ ] Gettext for new UI and test email text.
-- [ ] Feature roadmap and code guidelines updated.
+OTP 26+ enforces `verify_peer` by default, which fails for self-signed or internal SMTP server certificates.
+
+Both `tls_options: [verify: :verify_none]` (for STARTTLS, port 587) and `sockopts: [verify: :verify_none]` (for direct SSL, port 465) are set in `Mv.Mailer.smtp_config/0` to allow such certificates.
+
+For ENV-based boot config, the same options are set in `config/runtime.exs`.
+
+---
+
+## 11. Summary Checklist
+
+- [x] ENV: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`.
+- [x] ENV: `MAIL_FROM_NAME`, `MAIL_FROM_EMAIL` for sender identity.
+- [x] Settings: attributes and UI for host, port, username, password, TLS/SSL, from-name, from-email.
+- [x] Password from file: `SMTP_PASSWORD_FILE` supported in `runtime.exs`.
+- [x] Mailer: Swoosh SMTP adapter configured from merged ENV + Settings when SMTP is configured.
+- [x] Per-request SMTP config via `Mv.Mailer.smtp_config/0` for Settings-only scenarios.
+- [x] TLS certificate validation relaxed for OTP 27 (tls_options + sockopts).
+- [x] Prod warning: clear message in Settings when SMTP is not configured.
+- [x] Test email: form with recipient field, translatable content, classified success/error messages.
+- [x] AshAuthentication senders: graceful error handling (no crash on delivery failure).
+- [x] Gettext for all new UI strings, translated to German.
+- [x] Docs and code guidelines updated.
diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex
index bc2b1e7..827e194 100644
--- a/lib/membership/setting.ex
+++ b/lib/membership/setting.ex
@@ -58,7 +58,8 @@ defmodule Mv.Membership.Setting do
"""
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
@@ -73,8 +74,50 @@ defmodule Mv.Membership.Setting do
description "Global application settings (singleton resource)"
end
+ # All public attributes except smtp_password, used to exclude it from default reads.
+ # This list is used in the read prepare to prevent the sensitive password from being
+ # returned in standard reads (it can still be read via explicit select in Config).
+ @public_attributes [
+ :id,
+ :club_name,
+ :member_field_visibility,
+ :member_field_required,
+ :include_joining_cycle,
+ :default_membership_fee_type_id,
+ :vereinfacht_api_url,
+ :vereinfacht_api_key,
+ :vereinfacht_club_id,
+ :vereinfacht_app_url,
+ :oidc_client_id,
+ :oidc_base_url,
+ :oidc_redirect_uri,
+ :oidc_client_secret,
+ :oidc_admin_group_name,
+ :oidc_groups_claim,
+ :oidc_only,
+ :smtp_host,
+ :smtp_port,
+ :smtp_username,
+ :smtp_ssl,
+ :smtp_from_name,
+ :smtp_from_email,
+ :join_form_enabled,
+ :join_form_field_ids,
+ :join_form_field_required,
+ :inserted_at,
+ :updated_at
+ ]
+
actions do
- defaults [:read]
+ read :read do
+ primary? true
+
+ # smtp_password is excluded from the default select to prevent it from being returned
+ # in plaintext via standard reads. Config reads it via an explicit select internally.
+ prepare fn query, _context ->
+ Ash.Query.select(query, @public_attributes)
+ 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 +140,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 +176,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 +486,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
diff --git a/lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex b/lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex
index 393a220..7312b91 100644
--- a/lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex
+++ b/lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex
@@ -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
diff --git a/lib/mv/accounts/user/senders/send_password_reset_email.ex b/lib/mv/accounts/user/senders/send_password_reset_email.ex
index 74d5d47..e276e20 100644
--- a/lib/mv/accounts/user/senders/send_password_reset_email.ex
+++ b/lib/mv/accounts/user/senders/send_password_reset_email.ex
@@ -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
diff --git a/lib/mv/config.ex b/lib/mv/config.ex
index e176b8c..b824c1d 100644
--- a/lib/mv/config.ex
+++ b/lib/mv/config.ex
@@ -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
diff --git a/lib/mv/mailer.ex b/lib/mv/mailer.ex
index e78735b..8fca77b 100644
--- a/lib/mv/mailer.ex
+++ b/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("
#{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
diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex
index 3c75fa8..2662dd1 100644
--- a/lib/mv_web/live/global_settings_live.ex
+++ b/lib/mv_web/live/global_settings_live.ex
@@ -77,6 +77,18 @@ defmodule MvWeb.GlobalSettingsLive do
|> 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(: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 +149,6 @@ defmodule MvWeb.GlobalSettingsLive do
- <%!-- Board approval (future feature) --%>
-
-
-
-
-
<%!-- Field list header + Add button (left-aligned) --%>
{gettext("Fields on the join form")}
@@ -269,6 +266,181 @@ defmodule MvWeb.GlobalSettingsLive do
+ <%!-- SMTP / E-Mail Section --%>
+ <.form_section title={gettext("SMTP / E-Mail")}>
+ <%= if @smtp_env_configured do %>
+
+ {gettext("Some values are set via environment variables. Those fields are read-only.")}
+
+ <% end %>
+
+ <%= if Mix.env() == :prod and not @smtp_configured do %>
+
+ <.icon name="hero-exclamation-triangle" class="size-5 shrink-0 mt-0.5" />
+
+ {gettext(
+ "SMTP is not configured. Transactional emails (join confirmation, password reset, etc.) will not be delivered reliably."
+ )}
+
+
+ {gettext(
+ "The sender email must be owned by or authorized for the SMTP user on most servers."
+ )}
+
+ <.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")}
+
+
+
+ <%!-- Test email: use form phx-submit so the current input value is always sent (e.g. after paste without blur) --%>
+
+ """
+ end
+
+ defp smtp_test_result(%{result: {:error, :invalid_email_address}} = assigns) do
+ ~H"""
+
+ <.icon name="hero-x-circle" class="size-5 shrink-0" />
+ {gettext("Invalid email address. Please enter a valid recipient address.")}
+
+ """
+ end
+
+ defp smtp_test_result(%{result: {:error, :not_implemented}} = assigns) do
+ ~H"""
+
+ <.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
+ {gettext("SMTP is not configured. Please set at least the SMTP host.")}
+
+ """
+ end
+
+ defp smtp_test_result(%{result: {:error, :sender_rejected}} = assigns) do
+ ~H"""
+
+ <.icon name="hero-x-circle" class="size-5 shrink-0" />
+
+ {gettext(
+ "Sender address rejected. The \"Sender email\" must be owned by or authorized for the SMTP user."
+ )}
+
+
+ """
+ end
+
+ defp smtp_test_result(%{result: {:error, :auth_failed}} = assigns) do
+ ~H"""
+
+ <.icon name="hero-x-circle" class="size-5 shrink-0" />
+
+ {gettext("Authentication failed. Please check the SMTP username and password.")}
+
+
+ """
+ end
+
+ defp smtp_test_result(%{result: {:error, :recipient_rejected}} = assigns) do
+ ~H"""
+
+ <.icon name="hero-x-circle" class="size-5 shrink-0" />
+ {gettext("Recipient address rejected by the server.")}
+
+ """
+ end
+
+ defp smtp_test_result(%{result: {:error, :tls_failed}} = assigns) do
+ ~H"""
+
+ <.icon name="hero-x-circle" class="size-5 shrink-0" />
+
+ {gettext(
+ "TLS connection failed. Check the TLS/SSL setting and port (587 for TLS, 465 for SSL)."
+ )}
+
+
+ """
+ end
+
+ defp smtp_test_result(%{result: {:error, :connection_failed}} = assigns) do
+ ~H"""
+