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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -32,70 +32,83 @@ 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)|
|
||||
| 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
email =
|
||||
new()
|
||||
|> from(Mailer.mail_from())
|
||||
|> to(to_string(user.email))
|
||||
|> subject(subject)
|
||||
|> put_view(MvWeb.EmailsView)
|
||||
|> render_body("user_confirmation.html", assigns)
|
||||
|> Mailer.deliver!()
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
email =
|
||||
new()
|
||||
|> from(Mailer.mail_from())
|
||||
|> to(to_string(user.email))
|
||||
|> subject(subject)
|
||||
|> put_view(MvWeb.EmailsView)
|
||||
|> render_body("password_reset.html", assigns)
|
||||
|> Mailer.deliver!()
|
||||
|
||||
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
|
||||
|
|
|
|||
181
lib/mv/config.ex
181
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
|
||||
|
|
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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
|
|||
</label>
|
||||
</div>
|
||||
|
||||
<%!-- Board approval (future feature) --%>
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="join-form-board-approval-checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
checked={false}
|
||||
disabled
|
||||
aria-label={gettext("Board approval required (in development)")}
|
||||
/>
|
||||
<label for="join-form-board-approval-checkbox" class="text-base-content/60 font-medium">
|
||||
{gettext("Board approval required (in development)")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div :if={@join_form_enabled}>
|
||||
<%!-- Field list header + Add button (left-aligned) --%>
|
||||
<h3 class="font-medium mb-3">{gettext("Fields on the join form")}</h3>
|
||||
|
|
@ -269,6 +266,181 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
</div>
|
||||
</div>
|
||||
</.form_section>
|
||||
<%!-- SMTP / E-Mail Section --%>
|
||||
<.form_section title={gettext("SMTP / E-Mail")}>
|
||||
<%= if @smtp_env_configured do %>
|
||||
<p class="text-sm text-base-content/70 mb-4">
|
||||
{gettext("Some values are set via environment variables. Those fields are read-only.")}
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<%= if Mix.env() == :prod and not @smtp_configured do %>
|
||||
<div class="mb-4 flex items-start gap-2 p-3 rounded-lg border border-warning bg-warning/10 text-warning-aa text-sm">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{gettext(
|
||||
"SMTP is not configured. Transactional emails (join confirmation, password reset, etc.) will not be delivered reliably."
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<.form for={@form} id="smtp-form" phx-change="validate" phx-submit="save">
|
||||
<div class="grid gap-4">
|
||||
<.input
|
||||
field={@form[:smtp_host]}
|
||||
type="text"
|
||||
label={gettext("Host")}
|
||||
disabled={@smtp_host_env_set}
|
||||
placeholder={
|
||||
if(@smtp_host_env_set,
|
||||
do: gettext("From SMTP_HOST"),
|
||||
else: "smtp.example.com"
|
||||
)
|
||||
}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:smtp_port]}
|
||||
type="number"
|
||||
label={gettext("Port")}
|
||||
disabled={@smtp_port_env_set}
|
||||
placeholder={if(@smtp_port_env_set, do: gettext("From SMTP_PORT"), else: "587")}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:smtp_username]}
|
||||
type="text"
|
||||
label={gettext("Username")}
|
||||
disabled={@smtp_username_env_set}
|
||||
placeholder={
|
||||
if(@smtp_username_env_set,
|
||||
do: gettext("From SMTP_USERNAME"),
|
||||
else: "user@example.com"
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div class="form-control">
|
||||
<label class="label" for={@form[:smtp_password].id}>
|
||||
<span class="label-text">{gettext("Password")}</span>
|
||||
<%= if @smtp_password_set do %>
|
||||
<span class="label-text-alt">
|
||||
<.badge variant="neutral" size="sm">{gettext("(set)")}</.badge>
|
||||
</span>
|
||||
<% end %>
|
||||
</label>
|
||||
<.input
|
||||
field={@form[:smtp_password]}
|
||||
type="password"
|
||||
label=""
|
||||
disabled={@smtp_password_env_set}
|
||||
placeholder={
|
||||
if(@smtp_password_env_set,
|
||||
do: gettext("From SMTP_PASSWORD"),
|
||||
else:
|
||||
if(@smtp_password_set,
|
||||
do: gettext("Leave blank to keep current"),
|
||||
else: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<.input
|
||||
field={@form[:smtp_ssl]}
|
||||
type="select"
|
||||
label={gettext("TLS/SSL")}
|
||||
disabled={@smtp_ssl_env_set}
|
||||
options={[
|
||||
{gettext("TLS (port 587, recommended)"), "tls"},
|
||||
{gettext("SSL (port 465)"), "ssl"},
|
||||
{gettext("None (port 25, insecure)"), "none"}
|
||||
]}
|
||||
placeholder={if(@smtp_ssl_env_set, do: gettext("From SMTP_SSL"), else: nil)}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:smtp_from_email]}
|
||||
type="email"
|
||||
label={gettext("Sender email (From)")}
|
||||
disabled={@smtp_from_email_env_set}
|
||||
placeholder={
|
||||
if(@smtp_from_email_env_set,
|
||||
do: gettext("From MAIL_FROM_EMAIL"),
|
||||
else: "noreply@example.com"
|
||||
)
|
||||
}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:smtp_from_name]}
|
||||
type="text"
|
||||
label={gettext("Sender name (From)")}
|
||||
disabled={@smtp_from_name_env_set}
|
||||
placeholder={
|
||||
if(@smtp_from_name_env_set, do: gettext("From MAIL_FROM_NAME"), else: "Mila")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-base-content/60">
|
||||
{gettext(
|
||||
"The sender email must be owned by or authorized for the SMTP user on most servers."
|
||||
)}
|
||||
</p>
|
||||
<.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")}
|
||||
</.button>
|
||||
</.form>
|
||||
|
||||
<%!-- Test email: use form phx-submit so the current input value is always sent (e.g. after paste without blur) --%>
|
||||
<div class="mt-6">
|
||||
<h3 class="font-medium mb-3">{gettext("Test email")}</h3>
|
||||
<.form
|
||||
for={%{}}
|
||||
id="smtp-test-email-form"
|
||||
data-testid="smtp-test-email-form"
|
||||
phx-submit="send_smtp_test_email"
|
||||
class="space-y-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div class="form-control">
|
||||
<label class="label" for="smtp-test-to-email">
|
||||
<span class="label-text">{gettext("Recipient")}</span>
|
||||
</label>
|
||||
<input
|
||||
id="smtp-test-to-email"
|
||||
type="email"
|
||||
name="to_email"
|
||||
data-testid="smtp-test-email-input"
|
||||
value={@smtp_test_to_email}
|
||||
class="input input-bordered"
|
||||
placeholder="test@example.com"
|
||||
phx-change="update_smtp_test_to_email"
|
||||
/>
|
||||
</div>
|
||||
<.button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
data-testid="smtp-send-test-email"
|
||||
phx-disable-with={gettext("Sending...")}
|
||||
>
|
||||
{gettext("Send test email")}
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
<%= if @smtp_test_result do %>
|
||||
<div data-testid="smtp-test-result">
|
||||
<.smtp_test_result result={@smtp_test_result} />
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</.form_section>
|
||||
|
||||
<%!-- Vereinfacht Integration Section --%>
|
||||
<.form_section title={gettext("Vereinfacht Integration")}>
|
||||
<%= if @vereinfacht_env_configured do %>
|
||||
|
|
@ -516,6 +688,30 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
|
||||
end
|
||||
|
||||
# phx-change can fire with only _target (e.g. when focusing a field); avoid FunctionClauseError
|
||||
def handle_event("validate", params, socket) when is_map(params) do
|
||||
setting_params =
|
||||
params["setting"] || Map.get(socket.assigns.form.params || %{}, "setting") || %{}
|
||||
|
||||
{:noreply,
|
||||
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("update_smtp_test_to_email", %{"to_email" => email}, socket) do
|
||||
{:noreply, assign(socket, :smtp_test_to_email, email)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("send_smtp_test_email", params, socket) do
|
||||
to_email =
|
||||
(params["to_email"] || socket.assigns.smtp_test_to_email || "")
|
||||
|> String.trim()
|
||||
|
||||
result = Mv.Mailer.send_test_email(to_email)
|
||||
{:noreply, assign(socket, :smtp_test_result, result)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("test_vereinfacht_connection", _params, socket) do
|
||||
result = Mv.Vereinfacht.test_connection()
|
||||
|
|
@ -560,11 +756,13 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
@impl true
|
||||
def handle_event("save", %{"setting" => setting_params}, socket) do
|
||||
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||
# Never send blank API key / client secret so we do not overwrite stored secrets
|
||||
|
||||
# Never send blank API key / client secret / smtp password so we do not overwrite stored secrets
|
||||
setting_params_clean =
|
||||
setting_params
|
||||
|> drop_blank_vereinfacht_api_key()
|
||||
|> drop_blank_oidc_client_secret()
|
||||
|> drop_blank_smtp_password()
|
||||
|
||||
saves_vereinfacht = vereinfacht_params?(setting_params_clean)
|
||||
|
||||
|
|
@ -581,6 +779,10 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key))
|
||||
|> assign(:oidc_client_secret_set, present?(fresh_settings.oidc_client_secret))
|
||||
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|
||||
|> assign(:smtp_configured, Mv.Config.smtp_configured?())
|
||||
|> assign(:smtp_password_set, present?(Mv.Config.smtp_password()))
|
||||
|> 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(:vereinfacht_test_result, test_result)
|
||||
|> put_flash(:success, gettext("Settings updated successfully"))
|
||||
|> assign_form()
|
||||
|
|
@ -760,17 +962,29 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
end
|
||||
end
|
||||
|
||||
defp drop_blank_smtp_password(params) when is_map(params) do
|
||||
case params do
|
||||
%{"smtp_password" => v} when v in [nil, ""] ->
|
||||
Map.delete(params, "smtp_password")
|
||||
|
||||
_ ->
|
||||
params
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
||||
# Show ENV values in disabled fields (Vereinfacht and OIDC); never expose API key / client secret
|
||||
# Show ENV values in disabled fields (Vereinfacht, OIDC, SMTP); never expose secrets in form
|
||||
settings_display =
|
||||
settings
|
||||
|> merge_vereinfacht_env_values()
|
||||
|> merge_oidc_env_values()
|
||||
|> merge_smtp_env_values()
|
||||
|
||||
settings_for_form = %{
|
||||
settings_display
|
||||
| vereinfacht_api_key: nil,
|
||||
oidc_client_secret: nil
|
||||
oidc_client_secret: nil,
|
||||
smtp_password: nil
|
||||
}
|
||||
|
||||
form =
|
||||
|
|
@ -845,6 +1059,28 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
end
|
||||
end
|
||||
|
||||
defp merge_smtp_env_values(s) do
|
||||
s
|
||||
|> put_if_env_set(:smtp_host, Mv.Config.smtp_host_env_set?(), Mv.Config.smtp_host())
|
||||
|> put_if_env_set(:smtp_port, Mv.Config.smtp_port_env_set?(), Mv.Config.smtp_port())
|
||||
|> put_if_env_set(
|
||||
:smtp_username,
|
||||
Mv.Config.smtp_username_env_set?(),
|
||||
Mv.Config.smtp_username()
|
||||
)
|
||||
|> put_if_env_set(:smtp_ssl, Mv.Config.smtp_ssl_env_set?(), Mv.Config.smtp_ssl())
|
||||
|> put_if_env_set(
|
||||
:smtp_from_email,
|
||||
Mv.Config.mail_from_email_env_set?(),
|
||||
Mv.Config.mail_from_email()
|
||||
)
|
||||
|> put_if_env_set(
|
||||
:smtp_from_name,
|
||||
Mv.Config.mail_from_name_env_set?(),
|
||||
Mv.Config.mail_from_name()
|
||||
)
|
||||
end
|
||||
|
||||
defp enrich_sync_errors([]), do: []
|
||||
|
||||
defp enrich_sync_errors(errors) when is_list(errors) do
|
||||
|
|
@ -1018,6 +1254,115 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
"""
|
||||
end
|
||||
|
||||
# ---- SMTP test result component ----
|
||||
|
||||
attr :result, :any, required: true
|
||||
|
||||
defp smtp_test_result(%{result: {:ok, _}} = assigns) do
|
||||
~H"""
|
||||
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-success bg-success/10 text-success-aa text-sm">
|
||||
<.icon name="hero-check-circle" class="size-5 shrink-0" />
|
||||
<span>{gettext("Test email sent successfully.")}</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp smtp_test_result(%{result: {:error, :invalid_email_address}} = assigns) do
|
||||
~H"""
|
||||
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
||||
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
||||
<span>{gettext("Invalid email address. Please enter a valid recipient address.")}</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp smtp_test_result(%{result: {:error, :not_implemented}} = assigns) do
|
||||
~H"""
|
||||
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-warning bg-warning/10 text-warning-aa text-sm">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
|
||||
<span>{gettext("SMTP is not configured. Please set at least the SMTP host.")}</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp smtp_test_result(%{result: {:error, :sender_rejected}} = assigns) do
|
||||
~H"""
|
||||
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
||||
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
||||
<span>
|
||||
{gettext(
|
||||
"Sender address rejected. The \"Sender email\" must be owned by or authorized for the SMTP user."
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp smtp_test_result(%{result: {:error, :auth_failed}} = assigns) do
|
||||
~H"""
|
||||
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
||||
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
||||
<span>
|
||||
{gettext("Authentication failed. Please check the SMTP username and password.")}
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp smtp_test_result(%{result: {:error, :recipient_rejected}} = assigns) do
|
||||
~H"""
|
||||
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
||||
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
||||
<span>{gettext("Recipient address rejected by the server.")}</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp smtp_test_result(%{result: {:error, :tls_failed}} = assigns) do
|
||||
~H"""
|
||||
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
||||
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
||||
<span>
|
||||
{gettext(
|
||||
"TLS connection failed. Check the TLS/SSL setting and port (587 for TLS, 465 for SSL)."
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp smtp_test_result(%{result: {:error, :connection_failed}} = assigns) do
|
||||
~H"""
|
||||
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
||||
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
||||
<span>
|
||||
{gettext("Server unreachable. Check host and port.")}
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp smtp_test_result(%{result: {:error, {:smtp_error, message}}} = assigns)
|
||||
when is_binary(message) do
|
||||
~H"""
|
||||
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
||||
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
||||
<span>
|
||||
{gettext("SMTP error:")} {@result |> elem(1) |> elem(1)}
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp smtp_test_result(%{result: {:error, _reason}} = assigns) do
|
||||
~H"""
|
||||
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
||||
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
||||
<span>{gettext("Failed to send test email. Please check your SMTP configuration.")}</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# ---- Join form helper functions ----
|
||||
|
||||
defp assign_join_form_state(socket, settings, custom_fields) do
|
||||
|
|
|
|||
2
mix.exs
2
mix.exs
|
|
@ -67,6 +67,8 @@ defmodule Mv.MixProject do
|
|||
depth: 1},
|
||||
{:phoenix_swoosh, "~> 1.0"},
|
||||
{:swoosh, "~> 1.16"},
|
||||
# Required by Swoosh.Adapters.SMTP (and its Helpers use mimemail, which gen_smtp brings in)
|
||||
{:gen_smtp, "~> 1.0"},
|
||||
{:req, "~> 0.5"},
|
||||
{:telemetry_metrics, "~> 1.0"},
|
||||
{:telemetry_poller, "~> 1.0"},
|
||||
|
|
|
|||
1
mix.lock
1
mix.lock
|
|
@ -35,6 +35,7 @@
|
|||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
|
||||
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
|
||||
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
|
||||
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
|
||||
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
|
||||
"hammer": {:hex, :hammer, "7.2.0", "73113eca87f0fd20a6d3679c1182e8c4c1778266f61de4e9dc8c589dee156c30", [:mix], [], "hexpm", "c50fa865ddfe7b3d4f8a6941f56940679e02a9a1465b00668a95d140b101d828"},
|
||||
|
|
|
|||
|
|
@ -461,6 +461,7 @@ msgstr "Sonderzeichen empfohlen"
|
|||
msgid "Include both letters and numbers"
|
||||
msgstr "Buchstaben und Zahlen verwenden"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -3391,11 +3392,6 @@ msgstr "Keine Felder ausgewählt. Füge mindestens das E-Mail-Feld hinzu."
|
|||
msgid "Remove field %{label}"
|
||||
msgstr "Feld %{label} entfernen"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Board approval required (in development)"
|
||||
msgstr "Bestätigung durch Vorstand erforderlich (in Entwicklung)"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Individual fields"
|
||||
|
|
@ -3623,3 +3619,183 @@ msgstr "Offene Anträge"
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "Review by"
|
||||
msgstr "Geprüft von"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to send test email. Please check your SMTP configuration."
|
||||
msgstr "Test-E-Mail konnte nicht gesendet werden. Bitte prüfe deine SMTP-Konfiguration."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_HOST"
|
||||
msgstr "Von SMTP_HOST"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_PASSWORD"
|
||||
msgstr "Von SMTP_PASSWORD"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_PORT"
|
||||
msgstr "Von SMTP_PORT"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_SSL"
|
||||
msgstr "Von SMTP_SSL"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_USERNAME"
|
||||
msgstr "Von SMTP_USERNAME"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Host"
|
||||
msgstr "Host"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invalid email address. Please enter a valid recipient address."
|
||||
msgstr "Ungültige E-Mail-Adresse. Bitte gib eine gültige Empfängeradresse ein."
|
||||
|
||||
#: lib/mv/mailer.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Mila – Test email"
|
||||
msgstr "Mila – Test-E-Mail"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "None (port 25, insecure)"
|
||||
msgstr "Keines (Port 25, unsicher)"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Port"
|
||||
msgstr "Port"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Recipient"
|
||||
msgstr "Empfänger*in"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "SMTP / E-Mail"
|
||||
msgstr "SMTP / E-Mail"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "SMTP is not configured. Please set at least the SMTP host."
|
||||
msgstr "SMTP ist nicht konfiguriert. Bitte setze mindestens den SMTP-Host."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "SMTP is not configured. Transactional emails (join confirmation, password reset, etc.) will not be delivered reliably."
|
||||
msgstr "SMTP ist nicht konfiguriert. Transaktions-E-Mails (Beitrittsbestätigung, Passwort-Reset usw.) werden möglicherweise nicht zuverlässig zugestellt."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "SSL (port 465)"
|
||||
msgstr "SSL (Port 465)"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Save SMTP Settings"
|
||||
msgstr "SMTP-Einstellungen speichern"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Send test email"
|
||||
msgstr "Test-E-Mail senden"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Sending..."
|
||||
msgstr "Sende..."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "TLS (port 587, recommended)"
|
||||
msgstr "TLS (Port 587, empfohlen)"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "TLS/SSL"
|
||||
msgstr "TLS/SSL"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Test email"
|
||||
msgstr "Test-E-Mail"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Test email sent successfully."
|
||||
msgstr "Test-E-Mail erfolgreich gesendet."
|
||||
|
||||
#: lib/mv/mailer.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This is a test email sent from Mila. If you received this, your SMTP configuration is working correctly."
|
||||
msgstr "Dies ist eine Test-E-Mail von Mila. Wenn du diese erhalten hast, funktioniert deine SMTP-Konfiguration korrekt."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Username"
|
||||
msgstr "Benutzername"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Authentication failed. Please check the SMTP username and password."
|
||||
msgstr "Authentifizierung fehlgeschlagen. Bitte Benutzername und Passwort prüfen."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From MAIL_FROM_EMAIL"
|
||||
msgstr "Aus MAIL_FROM_EMAIL"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From MAIL_FROM_NAME"
|
||||
msgstr "Aus MAIL_FROM_NAME"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Recipient address rejected by the server."
|
||||
msgstr "Empfängeradresse vom Server abgelehnt."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "SMTP error:"
|
||||
msgstr "SMTP-Fehler:"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Sender address rejected. The \"Sender email\" must be owned by or authorized for the SMTP user."
|
||||
msgstr "Absenderadresse abgelehnt. Die \"Absender-E-Mail\" muss dem SMTP-Nutzer gehören oder für ihn erlaubt sein."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Sender email (From)"
|
||||
msgstr "Absender-E-Mail (Von)"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Sender name (From)"
|
||||
msgstr "Absendername (Von)"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Server unreachable. Check host and port."
|
||||
msgstr "Server nicht erreichbar. Host und Port prüfen."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "TLS connection failed. Check the TLS/SSL setting and port (587 for TLS, 465 for SSL)."
|
||||
msgstr "TLS-Verbindung fehlgeschlagen. TLS/SSL-Einstellung und Port prüfen (587 für TLS, 465 für SSL)."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "The sender email must be owned by or authorized for the SMTP user on most servers."
|
||||
msgstr "Die Absender-E-Mail muss auf den meisten SMTP-Servern dem SMTP-Nutzer gehören oder für ihn erlaubt sein."
|
||||
|
|
|
|||
|
|
@ -462,6 +462,7 @@ msgstr ""
|
|||
msgid "Include both letters and numbers"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -3391,11 +3392,6 @@ msgstr ""
|
|||
msgid "Remove field %{label}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Board approval required (in development)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Individual fields"
|
||||
|
|
@ -3623,3 +3619,183 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "Review by"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to send test email. Please check your SMTP configuration."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_HOST"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_PASSWORD"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_PORT"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_SSL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_USERNAME"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Host"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invalid email address. Please enter a valid recipient address."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv/mailer.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Mila – Test email"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "None (port 25, insecure)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Port"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Recipient"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "SMTP / E-Mail"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "SMTP is not configured. Please set at least the SMTP host."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "SMTP is not configured. Transactional emails (join confirmation, password reset, etc.) will not be delivered reliably."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "SSL (port 465)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Save SMTP Settings"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Send test email"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Sending..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "TLS (port 587, recommended)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "TLS/SSL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Test email"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Test email sent successfully."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv/mailer.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This is a test email sent from Mila. If you received this, your SMTP configuration is working correctly."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Username"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Authentication failed. Please check the SMTP username and password."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From MAIL_FROM_EMAIL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From MAIL_FROM_NAME"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Recipient address rejected by the server."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "SMTP error:"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Sender address rejected. The \"Sender email\" must be owned by or authorized for the SMTP user."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Sender email (From)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Sender name (From)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Server unreachable. Check host and port."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "TLS connection failed. Check the TLS/SSL setting and port (587 for TLS, 465 for SSL)."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "The sender email must be owned by or authorized for the SMTP user on most servers."
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -462,6 +462,7 @@ msgstr ""
|
|||
msgid "Include both letters and numbers"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -3391,11 +3392,6 @@ msgstr ""
|
|||
msgid "Remove field %{label}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Board approval required (in development)"
|
||||
msgstr "Board approval required (in development)"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Individual fields"
|
||||
|
|
@ -3623,3 +3619,183 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "Review by"
|
||||
msgstr "Review by"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to send test email. Please check your SMTP configuration."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_HOST"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_PASSWORD"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_PORT"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_SSL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_USERNAME"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Host"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invalid email address. Please enter a valid recipient address."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv/mailer.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Mila – Test email"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "None (port 25, insecure)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Port"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Recipient"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "SMTP / E-Mail"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "SMTP is not configured. Please set at least the SMTP host."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "SMTP is not configured. Transactional emails (join confirmation, password reset, etc.) will not be delivered reliably."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "SSL (port 465)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Save SMTP Settings"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Send test email"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Sending..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "TLS (port 587, recommended)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "TLS/SSL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Test email"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Test email sent successfully."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv/mailer.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This is a test email sent from Mila. If you received this, your SMTP configuration is working correctly."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Username"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Authentication failed. Please check the SMTP username and password."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From MAIL_FROM_EMAIL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From MAIL_FROM_NAME"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Recipient address rejected by the server."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "SMTP error:"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Sender address rejected. The \"Sender email\" must be owned by or authorized for the SMTP user."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Sender email (From)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Sender name (From)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Server unreachable. Check host and port."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "TLS connection failed. Check the TLS/SSL setting and port (587 for TLS, 465 for SSL)."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "The sender email must be owned by or authorized for the SMTP user on most servers."
|
||||
msgstr ""
|
||||
|
|
|
|||
27
priv/repo/migrations/20260311082352_add_smtp_to_settings.exs
Normal file
27
priv/repo/migrations/20260311082352_add_smtp_to_settings.exs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
defmodule Mv.Repo.Migrations.AddSmtpToSettings do
|
||||
@moduledoc """
|
||||
Adds SMTP configuration attributes to the settings table.
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:settings) do
|
||||
add :smtp_host, :text
|
||||
add :smtp_port, :bigint
|
||||
add :smtp_username, :text
|
||||
add :smtp_password, :text
|
||||
add :smtp_ssl, :text
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:settings) do
|
||||
remove :smtp_ssl
|
||||
remove :smtp_password
|
||||
remove :smtp_username
|
||||
remove :smtp_port
|
||||
remove :smtp_host
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
defmodule Mv.Repo.Migrations.AddMailFromToSettings do
|
||||
@moduledoc "Adds smtp_from_name and smtp_from_email attributes to the settings table."
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:settings) do
|
||||
add :smtp_from_name, :text
|
||||
add :smtp_from_email, :text
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:settings) do
|
||||
remove :smtp_from_email
|
||||
remove :smtp_from_name
|
||||
end
|
||||
end
|
||||
end
|
||||
243
priv/resource_snapshots/repo/join_requests/20260311082353.json
Normal file
243
priv/resource_snapshots/repo/join_requests/20260311082353.json
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "\"pending_confirmation\"",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "status",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "email",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "first_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "last_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "form_data",
|
||||
"type": "map"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "schema_version",
|
||||
"type": "bigint"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "confirmation_token_hash",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "confirmation_token_expires_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "confirmation_sent_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "submitted_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "approved_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "rejected_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": {
|
||||
"deferrable": false,
|
||||
"destination_attribute": "id",
|
||||
"destination_attribute_default": null,
|
||||
"destination_attribute_generated": null,
|
||||
"index?": false,
|
||||
"match_type": null,
|
||||
"match_with": null,
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"name": "join_requests_reviewed_by_user_id_fkey",
|
||||
"on_delete": null,
|
||||
"on_update": null,
|
||||
"primary_key?": true,
|
||||
"schema": "public",
|
||||
"table": "users"
|
||||
},
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "reviewed_by_user_id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "source",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "inserted_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "updated_at",
|
||||
"type": "utc_datetime_usec"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"create_table_options": null,
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "F01A57710F9E6C9CF0E006B3B956AE5930D2C12FC502BF31683BEB3A75094BD8",
|
||||
"identities": [],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "join_requests"
|
||||
}
|
||||
246
priv/resource_snapshots/repo/members/20260311082354.json
Normal file
246
priv/resource_snapshots/repo/members/20260311082354.json
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"uuid_generate_v7()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "first_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "last_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "email",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "join_date",
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "exit_date",
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "notes",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "city",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "street",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "house_number",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "postal_code",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "country",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "search_vector",
|
||||
"type": "tsvector"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "membership_fee_start_date",
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "vereinfacht_contact_id",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": {
|
||||
"deferrable": false,
|
||||
"destination_attribute": "id",
|
||||
"destination_attribute_default": null,
|
||||
"destination_attribute_generated": null,
|
||||
"index?": false,
|
||||
"match_type": null,
|
||||
"match_with": null,
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"name": "members_membership_fee_type_id_fkey",
|
||||
"on_delete": null,
|
||||
"on_update": null,
|
||||
"primary_key?": true,
|
||||
"schema": "public",
|
||||
"table": "membership_fee_types"
|
||||
},
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "membership_fee_type_id",
|
||||
"type": "uuid"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"create_table_options": null,
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "F704B80F108D01A7DF0C3B973FC94DBD778BD5555219BADB3C84EF1C91D9A3EF",
|
||||
"identities": [
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"base_filter": null,
|
||||
"index_name": "members_unique_email_index",
|
||||
"keys": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "email"
|
||||
}
|
||||
],
|
||||
"name": "unique_email",
|
||||
"nils_distinct?": true,
|
||||
"where": null
|
||||
}
|
||||
],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "members"
|
||||
}
|
||||
347
priv/resource_snapshots/repo/settings/20260311082355.json
Normal file
347
priv/resource_snapshots/repo/settings/20260311082355.json
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "club_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "member_field_visibility",
|
||||
"type": "map"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "member_field_required",
|
||||
"type": "map"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "true",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "include_joining_cycle",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "default_membership_fee_type_id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "vereinfacht_api_url",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "vereinfacht_api_key",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "vereinfacht_club_id",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "vereinfacht_app_url",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "oidc_client_id",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "oidc_base_url",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "oidc_redirect_uri",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "oidc_client_secret",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "oidc_admin_group_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "oidc_groups_claim",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "false",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "oidc_only",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "smtp_host",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "smtp_port",
|
||||
"type": "bigint"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "smtp_username",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "smtp_password",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "smtp_ssl",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "false",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "join_form_enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "[]",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "join_form_field_ids",
|
||||
"type": [
|
||||
"array",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "join_form_field_required",
|
||||
"type": "map"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "inserted_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "updated_at",
|
||||
"type": "utc_datetime_usec"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"create_table_options": null,
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "DDF99732D268EDCACB5F61CAA53B24F1EAA8EE2F54F4A31A2FB3FEF8DDC8BFAF",
|
||||
"identities": [],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "settings"
|
||||
}
|
||||
|
|
@ -8,7 +8,6 @@ defmodule Mv.Membership.SettingSmtpTest do
|
|||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.Membership
|
||||
|
||||
setup do
|
||||
|
|
|
|||
|
|
@ -37,9 +37,10 @@ defmodule Mv.MailerTest do
|
|||
assert {:ok, _} = Mailer.send_test_email(to_email)
|
||||
|
||||
assert_email_sent(fn email ->
|
||||
{_name, from_email} = Mailer.mail_from()
|
||||
from_addresses = Enum.map(email.from, &elem(&1, 1))
|
||||
from_email in from_addresses
|
||||
{_name, expected_from} = Mailer.mail_from()
|
||||
# email.from is a single {name, address} tuple in Swoosh, not a list
|
||||
{_name, actual_from} = email.from
|
||||
actual_from == expected_from
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -89,19 +89,16 @@ defmodule MvWeb.GlobalSettingsLiveTest do
|
|||
|
||||
test "send test email with valid address shows success or error result", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
# If test email UI exists: fill recipient, click button, assert result area updates
|
||||
# Uses data-testid or button text "Send test email" / "Test email"
|
||||
|
||||
if has_element?(view, "[data-testid='smtp-test-email-form']") do
|
||||
# Submit the test-email form (phx-submit) with a valid recipient address
|
||||
view
|
||||
|> element("[data-testid='smtp-test-email-input']")
|
||||
|> render_change(%{"to_email" => "test@example.com"})
|
||||
view
|
||||
|> element("[data-testid='smtp-send-test-email']")
|
||||
|> render_click()
|
||||
# Result is either success or error message
|
||||
|> form("[data-testid='smtp-test-email-form']", %{"to_email" => "test@example.com"})
|
||||
|> render_submit()
|
||||
|
||||
# Result area must appear regardless of success or error
|
||||
assert has_element?(view, "[data-testid='smtp-test-result']")
|
||||
else
|
||||
# Section not yet implemented: just ensure page still renders
|
||||
assert render(view) =~ "Settings"
|
||||
end
|
||||
end
|
||||
|
|
@ -109,7 +106,7 @@ defmodule MvWeb.GlobalSettingsLiveTest do
|
|||
test "shows warning when SMTP is not configured in production", %{conn: conn} do
|
||||
# Concept: in prod, show warning "SMTP is not configured. Transactional emails..."
|
||||
# In test we only check that the section exists; warning visibility is env-dependent
|
||||
{:ok, view, html} = live(conn, ~p"/settings")
|
||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||
assert html =~ "SMTP" or html =~ "E-Mail" or html =~ "Settings"
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue