Merge branch 'main' into feature/ux_button_concistency
This commit is contained in:
commit
3d72cb8753
29 changed files with 12039 additions and 9300 deletions
43
.drone.yml
43
.drone.yml
|
|
@ -219,24 +219,8 @@ trigger:
|
||||||
- main
|
- main
|
||||||
event:
|
event:
|
||||||
- push
|
- push
|
||||||
- tag
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: build-and-publish-container
|
|
||||||
image: plugins/docker
|
|
||||||
settings:
|
|
||||||
registry: git.local-it.org
|
|
||||||
repo: git.local-it.org/local-it/mitgliederverwaltung
|
|
||||||
username:
|
|
||||||
from_secret: DRONE_REGISTRY_USERNAME
|
|
||||||
password:
|
|
||||||
from_secret: DRONE_REGISTRY_TOKEN
|
|
||||||
auto_tag: true
|
|
||||||
auto_tag_suffix: ${DRONE_COMMIT_SHA:0:8}
|
|
||||||
when:
|
|
||||||
event:
|
|
||||||
- tag
|
|
||||||
|
|
||||||
- name: build-and-publish-container-branch
|
- name: build-and-publish-container-branch
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
settings:
|
settings:
|
||||||
|
|
@ -256,6 +240,33 @@ steps:
|
||||||
depends_on:
|
depends_on:
|
||||||
- check-fast
|
- check-fast
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: build-and-release
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
event:
|
||||||
|
- tag
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: build-and-publish-container
|
||||||
|
image: plugins/docker
|
||||||
|
settings:
|
||||||
|
registry: git.local-it.org
|
||||||
|
repo: git.local-it.org/local-it/mitgliederverwaltung
|
||||||
|
username:
|
||||||
|
from_secret: DRONE_REGISTRY_USERNAME
|
||||||
|
password:
|
||||||
|
from_secret: DRONE_REGISTRY_TOKEN
|
||||||
|
auto_tag: true
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
- tag
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
- check-fast
|
||||||
|
|
||||||
---
|
---
|
||||||
kind: pipeline
|
kind: pipeline
|
||||||
type: docker
|
type: docker
|
||||||
|
|
|
||||||
|
|
@ -1267,7 +1267,28 @@ mix hex.outdated
|
||||||
**Mailer and from address:**
|
**Mailer and from address:**
|
||||||
|
|
||||||
- `Mv.Mailer` (Swoosh) and `Mv.Mailer.mail_from/0` return the configured sender `{name, email}`.
|
- `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).
|
||||||
|
- **Sensitive settings in DB:** `smtp_password` and `oidc_client_secret` are excluded from the default read of the Setting resource; they are loaded only via explicit select when needed (e.g. `Mv.Config.smtp_password/0`, `Mv.Config.oidc_client_secret/0`). This avoids exposing secrets through `get_settings()`.
|
||||||
|
- 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:** Verify mode defaults to `verify_none` for self-signed/internal certs. Set `SMTP_VERIFY_PEER=true` (or `1`/`yes`) in prod when using public SMTP (Gmail, Mailgun). Config key `:smtp_verify_peer` is set in `runtime.exs` and read by `Mv.Mailer.smtp_config/0`.
|
||||||
|
- **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. Use `Application.get_env(:mv, :environment, :dev)` (or assign in mount) for environment checks in LiveView/templates; do not use `Mix.env()` at runtime (it is not available in releases).
|
||||||
|
- 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):**
|
**Unified layout (transactional emails):**
|
||||||
|
|
||||||
|
|
@ -1287,7 +1308,11 @@ new()
|
||||||
|> put_view(MvWeb.EmailsView)
|
|> put_view(MvWeb.EmailsView)
|
||||||
|> put_layout({MvWeb.EmailLayoutView, "layout.html"})
|
|> put_layout({MvWeb.EmailLayoutView, "layout.html"})
|
||||||
|> render_body("template_name.html", %{assigns})
|
|> 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
|
### 3.12 Internationalization: Gettext
|
||||||
|
|
@ -1315,13 +1340,16 @@ dgettext("auth", "Sign in with email")
|
||||||
**Extract and Merge:**
|
**Extract and Merge:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Extract new translatable strings
|
# Extract new translatable strings and merge into existing .po files (recommended)
|
||||||
mix gettext.extract
|
mix gettext.extract --merge
|
||||||
|
|
||||||
# Merge into existing translations
|
# Alternative: extract only, then merge separately
|
||||||
|
mix gettext.extract
|
||||||
mix gettext.merge priv/gettext --on-obsolete=mark_as_obsolete
|
mix gettext.merge priv/gettext --on-obsolete=mark_as_obsolete
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Gettext merge workflow:** Prefer `mix gettext.extract --merge` so the `.pot` template is regenerated from source and merged into all locale `.po` files in one step. Edit only the `msgstr` values in `.po` files for translations; do not manually change source references, entry order, or the `.pot` file structure. If Git merge conflicts appear in `.po` or `.pot` files, resolve by removing conflict markers (keeping both sides where appropriate), then run `mix gettext.extract --merge`. If the `.pot` file is corrupted, delete it and run `mix gettext.extract --merge` to regenerate it from source.
|
||||||
|
|
||||||
### 3.13 Task Runner: Just
|
### 3.13 Task Runner: Just
|
||||||
|
|
||||||
**Common Commands:**
|
**Common Commands:**
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,10 @@ config :mv,
|
||||||
generators: [timestamp_type: :utc_datetime],
|
generators: [timestamp_type: :utc_datetime],
|
||||||
ash_domains: [Mv.Membership, Mv.Accounts, Mv.MembershipFees, Mv.Authorization]
|
ash_domains: [Mv.Membership, Mv.Accounts, Mv.MembershipFees, Mv.Authorization]
|
||||||
|
|
||||||
|
# Environment (dev/test/prod). Use this instead of Mix.env() at runtime; Mix.env() is
|
||||||
|
# not available in releases. Set once at compile time via config_env().
|
||||||
|
config :mv, :environment, config_env()
|
||||||
|
|
||||||
# CSV Import configuration
|
# CSV Import configuration
|
||||||
config :mv,
|
config :mv,
|
||||||
csv_import: [
|
csv_import: [
|
||||||
|
|
@ -89,6 +93,10 @@ config :mv, MvWeb.Endpoint,
|
||||||
# at the `config/runtime.exs`.
|
# at the `config/runtime.exs`.
|
||||||
config :mv, Mv.Mailer, adapter: Swoosh.Adapters.Local
|
config :mv, Mv.Mailer, adapter: Swoosh.Adapters.Local
|
||||||
|
|
||||||
|
# SMTP TLS verification: false = allow self-signed/internal certs; true = verify_peer (use for public SMTP).
|
||||||
|
# Overridden in runtime.exs from SMTP_VERIFY_PEER when SMTP is configured via ENV in prod.
|
||||||
|
config :mv, :smtp_verify_peer, false
|
||||||
|
|
||||||
# Default mail "from" address for transactional emails (join confirmation,
|
# Default mail "from" address for transactional emails (join confirmation,
|
||||||
# user confirmation, password reset). Override in config/runtime.exs from ENV.
|
# user confirmation, password reset). Override in config/runtime.exs from ENV.
|
||||||
config :mv, :mail_from, {"Mila", "noreply@example.com"}
|
config :mv, :mail_from, {"Mila", "noreply@example.com"}
|
||||||
|
|
|
||||||
|
|
@ -223,19 +223,62 @@ if config_env() == :prod do
|
||||||
{System.get_env("MAIL_FROM_NAME", "Mila"),
|
{System.get_env("MAIL_FROM_NAME", "Mila"),
|
||||||
System.get_env("MAIL_FROM_EMAIL", "noreply@example.com")}
|
System.get_env("MAIL_FROM_EMAIL", "noreply@example.com")}
|
||||||
|
|
||||||
# In production you may need to configure the mailer to use a different adapter.
|
# SMTP configuration from environment variables (overrides base adapter in prod).
|
||||||
# Also, you may need to configure the Swoosh API client of your choice if you
|
# When SMTP_HOST is set, configure Swoosh to use the SMTP adapter at boot time.
|
||||||
# are not using SMTP. Here is an example of the configuration:
|
# If SMTP is configured only via Settings (Admin UI), the mailer builds the config
|
||||||
|
# per-send at runtime using Mv.Config.smtp_*() helpers.
|
||||||
#
|
#
|
||||||
# config :mv, Mv.Mailer,
|
# TLS/SSL options (tls_options, sockopts) are duplicated here and in Mv.Mailer.smtp_config/0
|
||||||
# adapter: Swoosh.Adapters.Mailgun,
|
# because boot config must be set in this file; the Mailer uses the same logic for
|
||||||
# api_key: System.get_env("MAILGUN_API_KEY"),
|
# Settings-only config. Keep verify behaviour in sync (see SMTP_VERIFY_PEER below).
|
||||||
# domain: System.get_env("MAILGUN_DOMAIN")
|
smtp_host_env = System.get_env("SMTP_HOST")
|
||||||
#
|
|
||||||
# For this example you need include a HTTP client required by Swoosh API client.
|
if smtp_host_env && String.trim(smtp_host_env) != "" do
|
||||||
# Swoosh supports Hackney, Req and Finch out of the box:
|
smtp_port_env =
|
||||||
#
|
case System.get_env("SMTP_PORT") do
|
||||||
# config :swoosh, :api_client, Swoosh.ApiClient.Hackney
|
nil -> 587
|
||||||
#
|
v -> String.to_integer(String.trim(v))
|
||||||
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
|
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_VERIFY_PEER: set to true/1/yes to enable TLS certificate verification (recommended
|
||||||
|
# for public SMTP like Gmail/Mailgun). Default false for self-signed/internal certs.
|
||||||
|
smtp_verify_peer =
|
||||||
|
(System.get_env("SMTP_VERIFY_PEER", "false") |> String.downcase()) in ~w(true 1 yes)
|
||||||
|
|
||||||
|
config :mv, :smtp_verify_peer, smtp_verify_peer
|
||||||
|
|
||||||
|
verify_mode = if smtp_verify_peer, do: :verify_peer, else: :verify_none
|
||||||
|
|
||||||
|
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,
|
||||||
|
# tls_options: STARTTLS (587); sockopts: direct SSL (465).
|
||||||
|
tls_options: [verify: verify_mode],
|
||||||
|
sockopts: [verify: verify_mode]
|
||||||
|
]
|
||||||
|
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|
||||||
|
|
||||||
|
config :mv, Mv.Mailer, smtp_opts
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,9 @@
|
||||||
**Open Issues:**
|
**Open Issues:**
|
||||||
- [#186](https://git.local-it.org/local-it/mitgliederverwaltung/issues/186) - Create Architecture docs in Repo (S, Low priority)
|
- [#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:**
|
**Missing Features:**
|
||||||
- ❌ Email templates configuration
|
- ❌ Email templates configuration
|
||||||
- ❌ System health dashboard
|
- ❌ System health dashboard
|
||||||
|
|
@ -287,6 +290,7 @@
|
||||||
- ✅ Swoosh mailer integration
|
- ✅ Swoosh mailer integration
|
||||||
- ✅ Email confirmation (via AshAuthentication)
|
- ✅ Email confirmation (via AshAuthentication)
|
||||||
- ✅ Password reset emails (via AshAuthentication)
|
- ✅ Password reset emails (via AshAuthentication)
|
||||||
|
- ✅ **SMTP configuration** via ENV and Admin Settings (see Admin Panel section)
|
||||||
- ⚠️ No member communication features
|
- ⚠️ No member communication features
|
||||||
|
|
||||||
**Missing Features:**
|
**Missing Features:**
|
||||||
|
|
|
||||||
124
docs/smtp-configuration-concept.md
Normal file
124
docs/smtp-configuration-concept.md
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
# SMTP Configuration – Concept
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Last updated:** 2026-03-12
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Enable configurable SMTP for sending transactional emails (join confirmation, user confirmation, password reset). Configuration via **environment variables** and **Admin Settings** (database), with the same precedence pattern as OIDC and Vereinfacht: **ENV overrides Settings**. Include a **test email** action in Settings (button + recipient field) with clear success/error feedback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Configuration Sources
|
||||||
|
|
||||||
|
| Source | Priority | Use case |
|
||||||
|
|----------|----------|-----------------------------------|
|
||||||
|
| ENV | 1 | Production, Docker, 12-factor |
|
||||||
|
| Settings | 2 | Admin UI, dev without ENV |
|
||||||
|
|
||||||
|
When an ENV variable is set, the corresponding Settings field is read-only in the UI (with hint "Set by environment").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 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 |
|
||||||
|
|
||||||
|
**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` 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 (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.
|
||||||
|
- **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. Sender Identity (`mail_from`)
|
||||||
|
|
||||||
|
`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. AshAuthentication Senders
|
||||||
|
|
||||||
|
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. TLS / SSL in OTP 27
|
||||||
|
|
||||||
|
OTP 26+ enforces `verify_peer` by default, which fails for self-signed or internal SMTP server certificates.
|
||||||
|
|
||||||
|
By default, TLS certificate verification is relaxed (`verify_none`) so self-signed or internal SMTP servers work. For public SMTP providers (Gmail, Mailgun, etc.) you can enable verification:
|
||||||
|
|
||||||
|
- **ENV (prod):** Set `SMTP_VERIFY_PEER=true` (or `1`/`yes`) when configuring SMTP via environment variables in `config/runtime.exs`. This sets `config :mv, :smtp_verify_peer` and is used for both boot-time and per-send config.
|
||||||
|
- **Default:** `false` (verify_none) for backward compatibility and internal/self-signed certs.
|
||||||
|
|
||||||
|
Both `tls_options` (STARTTLS, port 587) and `sockopts` (direct SSL, port 465) use the same verify mode. The logic is duplicated in `config/runtime.exs` (boot) and `Mv.Mailer.smtp_config/0` (Settings-only); keep in sync.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Follow-up / Future Work
|
||||||
|
|
||||||
|
- **SMTP password at-rest encryption:** The `smtp_password` attribute is currently stored in plaintext in the `settings` table. It is excluded from default reads (same pattern as `oidc_client_secret`); both are read only via explicit select when needed. For production systems at-rest encryption (e.g. with [Cloak](https://hexdocs.pm/cloak)) should be considered and tracked as a follow-up issue.
|
||||||
|
- **Error classification:** SMTP error categorization currently uses substring matching on server messages (e.g. "535", "authentication"). A more robust approach would be to pattern-match on `gen_smtp` error tuples first where possible, and fall back to string analysis only when needed. Server wording varies; consider extending patterns as new providers are used.
|
||||||
|
|
@ -56,14 +56,20 @@ defmodule Mv.Membership.Setting do
|
||||||
# Update membership fee settings
|
# Update membership fee settings
|
||||||
{:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: false})
|
{:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: false})
|
||||||
"""
|
"""
|
||||||
|
# primary_read_warning?: false — We use a custom read prepare that selects only public
|
||||||
|
# attributes and explicitly excludes smtp_password. Ash warns when the primary read does
|
||||||
|
# not load all attributes; we intentionally omit the password for security.
|
||||||
use Ash.Resource,
|
use Ash.Resource,
|
||||||
domain: Mv.Membership,
|
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)
|
# 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
|
@uuid_pattern ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||||
@valid_join_form_member_fields Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
@valid_join_form_member_fields Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||||
|
|
||||||
|
alias Ash.Resource.Info, as: ResourceInfo
|
||||||
|
|
||||||
postgres do
|
postgres do
|
||||||
table "settings"
|
table "settings"
|
||||||
repo Mv.Repo
|
repo Mv.Repo
|
||||||
|
|
@ -73,8 +79,27 @@ defmodule Mv.Membership.Setting do
|
||||||
description "Global application settings (singleton resource)"
|
description "Global application settings (singleton resource)"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Attributes excluded from the default read (sensitive data). Same pattern as smtp_password:
|
||||||
|
# read only via explicit select when needed; never loaded into default get_settings().
|
||||||
|
@excluded_from_read [:smtp_password, :oidc_client_secret]
|
||||||
|
|
||||||
actions do
|
actions do
|
||||||
defaults [:read]
|
read :read do
|
||||||
|
primary? true
|
||||||
|
|
||||||
|
# Exclude sensitive attributes (e.g. smtp_password) from default reads. Config reads
|
||||||
|
# them via explicit select when needed. Uses all attribute names minus excluded so
|
||||||
|
# the list stays correct when new attributes are added to the resource.
|
||||||
|
prepare fn query, _context ->
|
||||||
|
select_attrs =
|
||||||
|
__MODULE__
|
||||||
|
|> ResourceInfo.attribute_names()
|
||||||
|
|> MapSet.to_list()
|
||||||
|
|> Kernel.--(@excluded_from_read)
|
||||||
|
|
||||||
|
Ash.Query.select(query, select_attrs)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Internal create action - not exposed via code interface
|
# Internal create action - not exposed via code interface
|
||||||
# Used only as fallback in get_settings/0 if settings don't exist
|
# Used only as fallback in get_settings/0 if settings don't exist
|
||||||
|
|
@ -97,6 +122,13 @@ defmodule Mv.Membership.Setting do
|
||||||
:oidc_admin_group_name,
|
:oidc_admin_group_name,
|
||||||
:oidc_groups_claim,
|
:oidc_groups_claim,
|
||||||
:oidc_only,
|
:oidc_only,
|
||||||
|
:smtp_host,
|
||||||
|
:smtp_port,
|
||||||
|
:smtp_username,
|
||||||
|
:smtp_password,
|
||||||
|
:smtp_ssl,
|
||||||
|
:smtp_from_name,
|
||||||
|
:smtp_from_email,
|
||||||
:join_form_enabled,
|
:join_form_enabled,
|
||||||
:join_form_field_ids,
|
:join_form_field_ids,
|
||||||
:join_form_field_required
|
:join_form_field_required
|
||||||
|
|
@ -126,6 +158,13 @@ defmodule Mv.Membership.Setting do
|
||||||
:oidc_admin_group_name,
|
:oidc_admin_group_name,
|
||||||
:oidc_groups_claim,
|
:oidc_groups_claim,
|
||||||
:oidc_only,
|
:oidc_only,
|
||||||
|
:smtp_host,
|
||||||
|
:smtp_port,
|
||||||
|
:smtp_username,
|
||||||
|
:smtp_password,
|
||||||
|
:smtp_ssl,
|
||||||
|
:smtp_from_name,
|
||||||
|
:smtp_from_email,
|
||||||
:join_form_enabled,
|
:join_form_enabled,
|
||||||
:join_form_field_ids,
|
:join_form_field_ids,
|
||||||
:join_form_field_required
|
:join_form_field_required
|
||||||
|
|
@ -429,6 +468,52 @@ defmodule Mv.Membership.Setting do
|
||||||
description "When true and OIDC is configured, sign-in shows only OIDC (password login hidden)"
|
description "When true and OIDC is configured, sign-in shows only OIDC (password login hidden)"
|
||||||
end
|
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
|
# Join form (Beitrittsformular) settings
|
||||||
attribute :join_form_enabled, :boolean do
|
attribute :join_form_enabled, :boolean do
|
||||||
allow_nil? false
|
allow_nil? false
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
||||||
import Swoosh.Email
|
import Swoosh.Email
|
||||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
alias Mv.Mailer
|
alias Mv.Mailer
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -30,7 +32,8 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
||||||
- `_opts` - Additional options (unused)
|
- `_opts` - Additional options (unused)
|
||||||
|
|
||||||
## Returns
|
## 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
|
@impl true
|
||||||
def send(user, token, _) do
|
def send(user, token, _) do
|
||||||
|
|
@ -44,12 +47,24 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
||||||
locale: Gettext.get_locale(MvWeb.Gettext)
|
locale: Gettext.get_locale(MvWeb.Gettext)
|
||||||
}
|
}
|
||||||
|
|
||||||
new()
|
email =
|
||||||
|> from(Mailer.mail_from())
|
new()
|
||||||
|> to(to_string(user.email))
|
|> from(Mailer.mail_from())
|
||||||
|> subject(subject)
|
|> to(to_string(user.email))
|
||||||
|> put_view(MvWeb.EmailsView)
|
|> subject(subject)
|
||||||
|> render_body("user_confirmation.html", assigns)
|
|> put_view(MvWeb.EmailsView)
|
||||||
|> Mailer.deliver!()
|
|> render_body("user_confirmation.html", assigns)
|
||||||
|
|
||||||
|
case Mailer.deliver(email) do
|
||||||
|
{:ok, _} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error(
|
||||||
|
"Failed to send user confirmation email to #{user.email}: #{inspect(reason)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
|
||||||
import Swoosh.Email
|
import Swoosh.Email
|
||||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
alias Mv.Mailer
|
alias Mv.Mailer
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -30,7 +32,8 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
|
||||||
- `_opts` - Additional options (unused)
|
- `_opts` - Additional options (unused)
|
||||||
|
|
||||||
## Returns
|
## 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
|
@impl true
|
||||||
def send(user, token, _) do
|
def send(user, token, _) do
|
||||||
|
|
@ -44,12 +47,21 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
|
||||||
locale: Gettext.get_locale(MvWeb.Gettext)
|
locale: Gettext.get_locale(MvWeb.Gettext)
|
||||||
}
|
}
|
||||||
|
|
||||||
new()
|
email =
|
||||||
|> from(Mailer.mail_from())
|
new()
|
||||||
|> to(to_string(user.email))
|
|> from(Mailer.mail_from())
|
||||||
|> subject(subject)
|
|> to(to_string(user.email))
|
||||||
|> put_view(MvWeb.EmailsView)
|
|> subject(subject)
|
||||||
|> render_body("password_reset.html", assigns)
|
|> put_view(MvWeb.EmailsView)
|
||||||
|> Mailer.deliver!()
|
|> render_body("password_reset.html", assigns)
|
||||||
|
|
||||||
|
case Mailer.deliver(email) do
|
||||||
|
{:ok, _} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Failed to send password reset email to #{user.email}: #{inspect(reason)}")
|
||||||
|
:ok
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
227
lib/mv/config.ex
227
lib/mv/config.ex
|
|
@ -362,26 +362,41 @@ defmodule Mv.Config do
|
||||||
@doc """
|
@doc """
|
||||||
Returns the OIDC client secret.
|
Returns the OIDC client secret.
|
||||||
In production, uses the value from config :mv, :oidc (set by runtime.exs from OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE).
|
In production, uses the value from config :mv, :oidc (set by runtime.exs from OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE).
|
||||||
Otherwise ENV OIDC_CLIENT_SECRET, then Settings.
|
Otherwise ENV OIDC_CLIENT_SECRET, then Settings (read via explicit select; not in default get_settings).
|
||||||
"""
|
"""
|
||||||
@spec oidc_client_secret() :: String.t() | nil
|
@spec oidc_client_secret() :: String.t() | nil
|
||||||
def oidc_client_secret do
|
def oidc_client_secret do
|
||||||
case Application.get_env(:mv, :oidc) do
|
case Application.get_env(:mv, :oidc) do
|
||||||
oidc when is_list(oidc) -> oidc_client_secret_from_config(Keyword.get(oidc, :client_secret))
|
oidc when is_list(oidc) -> oidc_client_secret_from_config(Keyword.get(oidc, :client_secret))
|
||||||
_ -> env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
|
_ -> oidc_client_secret_from_env_or_settings()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns whether the OIDC client secret is set in Settings (for UI badge). Does not expose the value.
|
||||||
|
"""
|
||||||
|
@spec oidc_client_secret_set?() :: boolean()
|
||||||
|
def oidc_client_secret_set? do
|
||||||
|
present?(get_oidc_client_secret_from_settings())
|
||||||
|
end
|
||||||
|
|
||||||
defp oidc_client_secret_from_config(nil),
|
defp oidc_client_secret_from_config(nil),
|
||||||
do: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
|
do: oidc_client_secret_from_env_or_settings()
|
||||||
|
|
||||||
defp oidc_client_secret_from_config(secret) when is_binary(secret) do
|
defp oidc_client_secret_from_config(secret) when is_binary(secret) do
|
||||||
s = String.trim(secret)
|
s = String.trim(secret)
|
||||||
if s != "", do: s, else: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
|
if s != "", do: s, else: oidc_client_secret_from_env_or_settings()
|
||||||
end
|
end
|
||||||
|
|
||||||
defp oidc_client_secret_from_config(_),
|
defp oidc_client_secret_from_config(_),
|
||||||
do: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
|
do: oidc_client_secret_from_env_or_settings()
|
||||||
|
|
||||||
|
defp oidc_client_secret_from_env_or_settings do
|
||||||
|
case System.get_env("OIDC_CLIENT_SECRET") do
|
||||||
|
nil -> get_oidc_client_secret_from_settings()
|
||||||
|
value -> trim_nil(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the OIDC admin group name (for role sync). ENV first, then Settings.
|
Returns the OIDC admin group name (for role sync). ENV first, then Settings.
|
||||||
|
|
@ -449,4 +464,206 @@ defmodule Mv.Config do
|
||||||
def oidc_admin_group_name_env_set?, do: env_set?("OIDC_ADMIN_GROUP_NAME")
|
def oidc_admin_group_name_env_set?, do: env_set?("OIDC_ADMIN_GROUP_NAME")
|
||||||
def oidc_groups_claim_env_set?, do: env_set?("OIDC_GROUPS_CLAIM")
|
def oidc_groups_claim_env_set?, do: env_set?("OIDC_GROUPS_CLAIM")
|
||||||
def oidc_only_env_set?, do: env_set?("OIDC_ONLY")
|
def oidc_only_env_set?, do: env_set?("OIDC_ONLY")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SMTP configuration – ENV overrides Settings; see docs/smtp-configuration-concept.md
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns SMTP host. ENV `SMTP_HOST` overrides Settings.
|
||||||
|
"""
|
||||||
|
@spec smtp_host() :: String.t() | nil
|
||||||
|
def smtp_host do
|
||||||
|
smtp_env_or_setting("SMTP_HOST", :smtp_host)
|
||||||
|
end
|
||||||
|
|
||||||
|
@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
|
||||||
|
case System.get_env("SMTP_PORT") do
|
||||||
|
nil ->
|
||||||
|
get_from_settings_integer(:smtp_port)
|
||||||
|
|
||||||
|
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
|
||||||
|
smtp_env_or_setting("SMTP_USERNAME", :smtp_username)
|
||||||
|
end
|
||||||
|
|
||||||
|
@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
|
||||||
|
case System.get_env("SMTP_PASSWORD") do
|
||||||
|
nil -> smtp_password_from_file_or_settings()
|
||||||
|
value -> trim_nil(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
||||||
|
smtp_env_or_setting("SMTP_SSL", :smtp_ssl)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns true when SMTP is configured (host present from ENV or Settings).
|
||||||
|
"""
|
||||||
|
@spec smtp_configured?() :: boolean()
|
||||||
|
def smtp_configured? do
|
||||||
|
present?(smtp_host())
|
||||||
|
end
|
||||||
|
|
||||||
|
@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
|
||||||
|
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
|
||||||
|
|
||||||
|
# Reads the OIDC client secret via explicit select (excluded from default read, same as smtp_password).
|
||||||
|
defp get_oidc_client_secret_from_settings do
|
||||||
|
query = Ash.Query.select(Mv.Membership.Setting, [:id, :oidc_client_secret])
|
||||||
|
|
||||||
|
case Ash.read_one(query, authorize?: false, domain: Mv.Membership) do
|
||||||
|
{:ok, settings} when not is_nil(settings) ->
|
||||||
|
settings |> Map.get(:oidc_client_secret) |> trim_nil()
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
193
lib/mv/mailer.ex
193
lib/mv/mailer.ex
|
|
@ -4,16 +4,199 @@ defmodule Mv.Mailer do
|
||||||
|
|
||||||
Use `mail_from/0` for the configured sender address (join confirmation,
|
Use `mail_from/0` for the configured sender address (join confirmation,
|
||||||
user confirmation, password reset).
|
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
|
use Swoosh.Mailer, otp_app: :mv
|
||||||
|
|
||||||
@doc """
|
import Swoosh.Email
|
||||||
Returns the configured "from" address for transactional emails.
|
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||||
|
|
||||||
Configure in config.exs or runtime.exs as `config :mv, :mail_from, {name, email}`.
|
require Logger
|
||||||
Default: `{"Mila", "noreply@example.com"}`.
|
|
||||||
|
# Simple format check for test-email recipient only (e.g. allows a@b.c). Not for strict RFC validation.
|
||||||
|
@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
|
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
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sends a test email to the given address. Used from Global Settings SMTP section.
|
||||||
|
|
||||||
|
Returns `{:ok, email}` on success, `{:error, reason}` on failure.
|
||||||
|
The `reason` is a classified atom for known error categories, or `{:smtp_error, message}`
|
||||||
|
for SMTP-level errors with a human-readable message, or the raw term for unknown errors.
|
||||||
|
"""
|
||||||
|
@spec send_test_email(String.t()) ::
|
||||||
|
{:ok, Swoosh.Email.t()} | {:error, atom() | {:smtp_error, String.t()} | term()}
|
||||||
|
def send_test_email(to_email) when is_binary(to_email) do
|
||||||
|
if valid_email?(to_email) do
|
||||||
|
subject = gettext("Mila – Test email")
|
||||||
|
|
||||||
|
body =
|
||||||
|
gettext(
|
||||||
|
"This is a test email sent from Mila. If you received this, your SMTP configuration is working correctly."
|
||||||
|
)
|
||||||
|
|
||||||
|
email =
|
||||||
|
new()
|
||||||
|
|> from(mail_from())
|
||||||
|
|> to(to_email)
|
||||||
|
|> subject(subject)
|
||||||
|
|> text_body(body)
|
||||||
|
|> html_body("<p>#{body}</p>")
|
||||||
|
|
||||||
|
case deliver(email, smtp_config()) do
|
||||||
|
{:ok, _} = ok ->
|
||||||
|
ok
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
classified = classify_smtp_error(reason)
|
||||||
|
Logger.warning("SMTP test email failed: #{inspect(reason)}")
|
||||||
|
{:error, classified}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:error, :invalid_email_address}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_test_email(_), do: {:error, :invalid_email_address}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Builds the per-send SMTP config from `Mv.Config` when SMTP is configured via
|
||||||
|
Settings only (not boot-time ENV). Returns an empty list when the mailer is
|
||||||
|
already configured at boot (ENV-based), so Swoosh uses the Application config.
|
||||||
|
|
||||||
|
The return value must be a flat keyword list (adapter, relay, port, ...).
|
||||||
|
Swoosh merges it with Application config; top-level keys override the mailer's
|
||||||
|
default adapter (e.g. Local in dev), so this delivery uses SMTP.
|
||||||
|
"""
|
||||||
|
@spec smtp_config() :: keyword()
|
||||||
|
def smtp_config do
|
||||||
|
if Mv.Config.smtp_configured?() and not boot_smtp_configured?() do
|
||||||
|
host = Mv.Config.smtp_host()
|
||||||
|
port = Mv.Config.smtp_port() || 587
|
||||||
|
username = Mv.Config.smtp_username()
|
||||||
|
password = Mv.Config.smtp_password()
|
||||||
|
ssl_mode = Mv.Config.smtp_ssl() || "tls"
|
||||||
|
|
||||||
|
verify_mode =
|
||||||
|
if Application.get_env(:mv, :smtp_verify_peer, false),
|
||||||
|
do: :verify_peer,
|
||||||
|
else: :verify_none
|
||||||
|
|
||||||
|
[
|
||||||
|
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,
|
||||||
|
# tls_options: STARTTLS (587); sockopts: direct SSL (465). Verify from :smtp_verify_peer (ENV SMTP_VERIFY_PEER).
|
||||||
|
tls_options: [verify: verify_mode],
|
||||||
|
sockopts: [verify: verify_mode]
|
||||||
|
]
|
||||||
|
|> 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
|
end
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,11 @@ defmodule MvWeb.Layouts do
|
||||||
slot :inner_block, required: true
|
slot :inner_block, required: true
|
||||||
|
|
||||||
def app(assigns) do
|
def app(assigns) do
|
||||||
club_name = get_club_name()
|
# Single get_settings() for layout; derive club_name and join_form_enabled to avoid duplicate query.
|
||||||
join_form_enabled = Mv.Membership.join_form_enabled?()
|
%{club_name: club_name, join_form_enabled: join_form_enabled} = get_layout_settings()
|
||||||
|
|
||||||
# TODO: get_join_form_enabled and unprocessed count run on every page load; consider
|
# TODO: unprocessed count runs on every page load when join form enabled; consider
|
||||||
# loading count only on navigation or caching briefly if performance becomes an issue.
|
# loading only on navigation or caching briefly if performance becomes an issue.
|
||||||
unprocessed_join_requests_count =
|
unprocessed_join_requests_count =
|
||||||
get_unprocessed_join_requests_count(assigns.current_user, join_form_enabled)
|
get_unprocessed_join_requests_count(assigns.current_user, join_form_enabled)
|
||||||
|
|
||||||
|
|
@ -129,12 +129,17 @@ defmodule MvWeb.Layouts do
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper function to get club name from settings
|
# Single settings read for layout; returns club_name and join_form_enabled to avoid duplicate get_settings().
|
||||||
# Falls back to "Mitgliederverwaltung" if settings can't be loaded
|
defp get_layout_settings do
|
||||||
defp get_club_name do
|
|
||||||
case Mv.Membership.get_settings() do
|
case Mv.Membership.get_settings() do
|
||||||
{:ok, settings} -> settings.club_name
|
{:ok, settings} ->
|
||||||
_ -> "Mitgliederverwaltung"
|
%{
|
||||||
|
club_name: settings.club_name || "Mitgliederverwaltung",
|
||||||
|
join_form_enabled: settings.join_form_enabled == true
|
||||||
|
}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
%{club_name: "Mitgliederverwaltung", join_form_enabled: false}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,11 +54,14 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
actor = MvWeb.LiveHelpers.current_actor(socket)
|
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||||
custom_fields = load_custom_fields(actor)
|
custom_fields = load_custom_fields(actor)
|
||||||
|
|
||||||
|
environment = Application.get_env(:mv, :environment, :dev)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Settings"))
|
|> assign(:page_title, gettext("Settings"))
|
||||||
|> assign(:settings, settings)
|
|> assign(:settings, settings)
|
||||||
|> assign(:locale, locale)
|
|> assign(:locale, locale)
|
||||||
|
|> assign(:environment, environment)
|
||||||
|> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?())
|
|> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?())
|
||||||
|> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?())
|
|> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?())
|
||||||
|> assign(:vereinfacht_api_key_env_set, Mv.Config.vereinfacht_api_key_env_set?())
|
|> assign(:vereinfacht_api_key_env_set, Mv.Config.vereinfacht_api_key_env_set?())
|
||||||
|
|
@ -76,7 +79,19 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
|> assign(:oidc_groups_claim_env_set, Mv.Config.oidc_groups_claim_env_set?())
|
|> assign(:oidc_groups_claim_env_set, Mv.Config.oidc_groups_claim_env_set?())
|
||||||
|> assign(:oidc_only_env_set, Mv.Config.oidc_only_env_set?())
|
|> assign(:oidc_only_env_set, Mv.Config.oidc_only_env_set?())
|
||||||
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|
||||||
|> assign(:oidc_client_secret_set, present?(settings.oidc_client_secret))
|
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|
||||||
|
|> 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_join_form_state(settings, custom_fields)
|
||||||
|> assign_form()
|
|> assign_form()
|
||||||
|
|
||||||
|
|
@ -137,21 +152,6 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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}>
|
<div :if={@join_form_enabled}>
|
||||||
<%!-- Field list header + Add button (left-aligned) --%>
|
<%!-- Field list header + Add button (left-aligned) --%>
|
||||||
<h3 class="font-medium mb-3">{gettext("Fields on the join form")}</h3>
|
<h3 class="font-medium mb-3">{gettext("Fields on the join form")}</h3>
|
||||||
|
|
@ -269,6 +269,181 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</.form_section>
|
</.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 @environment == :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 --%>
|
<%!-- Vereinfacht Integration Section --%>
|
||||||
<.form_section title={gettext("Accounting-Software (Vereinfacht) Integration")}>
|
<.form_section title={gettext("Accounting-Software (Vereinfacht) Integration")}>
|
||||||
<%= if @vereinfacht_env_configured do %>
|
<%= if @vereinfacht_env_configured do %>
|
||||||
|
|
@ -516,6 +691,27 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
|
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# phx-change can fire without "setting" (e.g. only _target when focusing). Do not validate
|
||||||
|
# with previous form params to avoid surprising behaviour; wait for the next event with setting data.
|
||||||
|
def handle_event("validate", _params, socket) do
|
||||||
|
{:noreply, socket}
|
||||||
|
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
|
@impl true
|
||||||
def handle_event("test_vereinfacht_connection", _params, socket) do
|
def handle_event("test_vereinfacht_connection", _params, socket) do
|
||||||
result = Mv.Vereinfacht.test_connection()
|
result = Mv.Vereinfacht.test_connection()
|
||||||
|
|
@ -560,11 +756,13 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("save", %{"setting" => setting_params}, socket) do
|
def handle_event("save", %{"setting" => setting_params}, socket) do
|
||||||
actor = MvWeb.LiveHelpers.current_actor(socket)
|
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_clean =
|
||||||
setting_params
|
setting_params
|
||||||
|> drop_blank_vereinfacht_api_key()
|
|> drop_blank_vereinfacht_api_key()
|
||||||
|> drop_blank_oidc_client_secret()
|
|> drop_blank_oidc_client_secret()
|
||||||
|
|> drop_blank_smtp_password()
|
||||||
|
|
||||||
saves_vereinfacht = vereinfacht_params?(setting_params_clean)
|
saves_vereinfacht = vereinfacht_params?(setting_params_clean)
|
||||||
|
|
||||||
|
|
@ -579,8 +777,12 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
socket
|
socket
|
||||||
|> assign(:settings, fresh_settings)
|
|> assign(:settings, fresh_settings)
|
||||||
|> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key))
|
|> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key))
|
||||||
|> assign(:oidc_client_secret_set, present?(fresh_settings.oidc_client_secret))
|
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|
||||||
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|
|> 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)
|
|> assign(:vereinfacht_test_result, test_result)
|
||||||
|> put_flash(:success, gettext("Settings updated successfully"))
|
|> put_flash(:success, gettext("Settings updated successfully"))
|
||||||
|> assign_form()
|
|> assign_form()
|
||||||
|
|
@ -760,17 +962,29 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
end
|
end
|
||||||
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
|
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_display =
|
||||||
settings
|
settings
|
||||||
|> merge_vereinfacht_env_values()
|
|> merge_vereinfacht_env_values()
|
||||||
|> merge_oidc_env_values()
|
|> merge_oidc_env_values()
|
||||||
|
|> merge_smtp_env_values()
|
||||||
|
|
||||||
settings_for_form = %{
|
settings_for_form = %{
|
||||||
settings_display
|
settings_display
|
||||||
| vereinfacht_api_key: nil,
|
| vereinfacht_api_key: nil,
|
||||||
oidc_client_secret: nil
|
oidc_client_secret: nil,
|
||||||
|
smtp_password: nil
|
||||||
}
|
}
|
||||||
|
|
||||||
form =
|
form =
|
||||||
|
|
@ -845,6 +1059,28 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
end
|
end
|
||||||
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([]), do: []
|
||||||
|
|
||||||
defp enrich_sync_errors(errors) when is_list(errors) do
|
defp enrich_sync_errors(errors) when is_list(errors) do
|
||||||
|
|
@ -1018,6 +1254,115 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
"""
|
"""
|
||||||
end
|
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 ----
|
# ---- Join form helper functions ----
|
||||||
|
|
||||||
defp assign_join_form_state(socket, settings, custom_fields) do
|
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},
|
depth: 1},
|
||||||
{:phoenix_swoosh, "~> 1.0"},
|
{:phoenix_swoosh, "~> 1.0"},
|
||||||
{:swoosh, "~> 1.16"},
|
{: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"},
|
{:req, "~> 0.5"},
|
||||||
{:telemetry_metrics, "~> 1.0"},
|
{:telemetry_metrics, "~> 1.0"},
|
||||||
{:telemetry_poller, "~> 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"},
|
"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"},
|
"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"},
|
"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"},
|
"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"},
|
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
|
||||||
"hammer": {:hex, :hammer, "7.2.0", "73113eca87f0fd20a6d3679c1182e8c4c1778266f61de4e9dc8c589dee156c30", [:mix], [], "hexpm", "c50fa865ddfe7b3d4f8a6941f56940679e02a9a1465b00668a95d140b101d828"},
|
"hammer": {:hex, :hammer, "7.2.0", "73113eca87f0fd20a6d3679c1182e8c4c1778266f61de4e9dc8c589dee156c30", [:mix], [], "hexpm", "c50fa865ddfe7b3d4f8a6941f56940679e02a9a1465b00668a95d140b101d828"},
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
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"
|
||||||
|
}
|
||||||
62
test/membership/setting_smtp_test.exs
Normal file
62
test/membership/setting_smtp_test.exs
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
defmodule Mv.Membership.SettingSmtpTest do
|
||||||
|
@moduledoc """
|
||||||
|
Unit tests for Setting resource SMTP attributes.
|
||||||
|
|
||||||
|
TDD: tests expect smtp_host, smtp_port, smtp_username, smtp_password, smtp_ssl
|
||||||
|
to be accepted on update and persisted. Password must not be exposed in plaintext
|
||||||
|
when reading settings (sensitive). Tests will fail until Setting has these attributes.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
alias Mv.Membership
|
||||||
|
|
||||||
|
setup do
|
||||||
|
{:ok, settings} = Membership.get_settings()
|
||||||
|
# Save current SMTP values to restore in on_exit (when attributes exist)
|
||||||
|
saved = %{
|
||||||
|
smtp_host: Map.get(settings, :smtp_host),
|
||||||
|
smtp_port: Map.get(settings, :smtp_port),
|
||||||
|
smtp_username: Map.get(settings, :smtp_username),
|
||||||
|
smtp_ssl: Map.get(settings, :smtp_ssl)
|
||||||
|
}
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
{:ok, s} = Membership.get_settings()
|
||||||
|
attrs = Enum.reject(saved, fn {_k, v} -> is_nil(v) end) |> Map.new()
|
||||||
|
if attrs != %{}, do: Membership.update_settings(s, attrs)
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok, settings: settings, saved: saved}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "SMTP attributes update and persistence" do
|
||||||
|
test "update_settings accepts smtp_host, smtp_port, smtp_username, smtp_ssl and persists", %{
|
||||||
|
settings: settings
|
||||||
|
} do
|
||||||
|
attrs = %{
|
||||||
|
smtp_host: "smtp.example.com",
|
||||||
|
smtp_port: 587,
|
||||||
|
smtp_username: "user",
|
||||||
|
smtp_ssl: "tls"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, updated} = Membership.update_settings(settings, attrs)
|
||||||
|
assert updated.smtp_host == "smtp.example.com"
|
||||||
|
assert updated.smtp_port == 587
|
||||||
|
assert updated.smtp_username == "user"
|
||||||
|
assert updated.smtp_ssl == "tls"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "smtp_password can be set and is not exposed in plaintext when reading settings", %{
|
||||||
|
settings: settings
|
||||||
|
} do
|
||||||
|
secret = "sensitive-password-#{System.unique_integer([:positive])}"
|
||||||
|
assert {:ok, _} = Membership.update_settings(settings, %{smtp_password: secret})
|
||||||
|
|
||||||
|
{:ok, read_back} = Membership.get_settings()
|
||||||
|
# Sensitive: raw password must not be returned (e.g. nil or redacted)
|
||||||
|
refute read_back.smtp_password == secret,
|
||||||
|
"smtp_password must not be returned in plaintext when reading settings"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
129
test/mv/config_smtp_test.exs
Normal file
129
test/mv/config_smtp_test.exs
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
defmodule Mv.ConfigSmtpTest do
|
||||||
|
@moduledoc """
|
||||||
|
Unit tests for Mv.Config SMTP-related helpers.
|
||||||
|
|
||||||
|
ENV overrides Settings (same pattern as OIDC/Vereinfacht). Uses real ENV and
|
||||||
|
Settings; no mocking so we test the actual precedence. async: false because
|
||||||
|
we mutate ENV.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
describe "smtp_host/0" do
|
||||||
|
test "returns ENV value when SMTP_HOST is set" do
|
||||||
|
set_smtp_env("SMTP_HOST", "smtp.example.com")
|
||||||
|
assert Mv.Config.smtp_host() == "smtp.example.com"
|
||||||
|
after
|
||||||
|
clear_smtp_env()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil when SMTP_HOST is not set and Settings have no smtp_host" do
|
||||||
|
clear_smtp_env()
|
||||||
|
assert Mv.Config.smtp_host() == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "smtp_port/0" do
|
||||||
|
test "returns parsed integer when SMTP_PORT ENV is set" do
|
||||||
|
set_smtp_env("SMTP_PORT", "587")
|
||||||
|
assert Mv.Config.smtp_port() == 587
|
||||||
|
after
|
||||||
|
clear_smtp_env()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil or default when SMTP_PORT is not set" do
|
||||||
|
clear_smtp_env()
|
||||||
|
port = Mv.Config.smtp_port()
|
||||||
|
assert port == nil or (is_integer(port) and port in [25, 465, 587])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "smtp_configured?/0" do
|
||||||
|
test "returns true when smtp_host is present (from ENV or Settings)" do
|
||||||
|
set_smtp_env("SMTP_HOST", "smtp.example.com")
|
||||||
|
assert Mv.Config.smtp_configured?() == true
|
||||||
|
after
|
||||||
|
clear_smtp_env()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns false when no SMTP host is set" do
|
||||||
|
clear_smtp_env()
|
||||||
|
refute Mv.Config.smtp_configured?()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "smtp_env_configured?/0" do
|
||||||
|
test "returns true when any SMTP ENV variable is set" do
|
||||||
|
set_smtp_env("SMTP_HOST", "smtp.example.com")
|
||||||
|
assert Mv.Config.smtp_env_configured?() == true
|
||||||
|
after
|
||||||
|
clear_smtp_env()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns false when no SMTP ENV variables are set" do
|
||||||
|
clear_smtp_env()
|
||||||
|
refute Mv.Config.smtp_env_configured?()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "smtp_password/0 and SMTP_PASSWORD_FILE" do
|
||||||
|
test "returns value from SMTP_PASSWORD when set" do
|
||||||
|
set_smtp_env("SMTP_PASSWORD", "env-secret")
|
||||||
|
assert Mv.Config.smtp_password() == "env-secret"
|
||||||
|
after
|
||||||
|
clear_smtp_env()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns content of file when SMTP_PASSWORD_FILE is set and SMTP_PASSWORD is not" do
|
||||||
|
clear_smtp_env()
|
||||||
|
path = Path.join(System.tmp_dir!(), "mv_smtp_test_#{System.unique_integer([:positive])}")
|
||||||
|
File.write!(path, "file-secret\n")
|
||||||
|
Process.put(:smtp_password_file_path, path)
|
||||||
|
set_smtp_env("SMTP_PASSWORD_FILE", path)
|
||||||
|
assert Mv.Config.smtp_password() == "file-secret"
|
||||||
|
after
|
||||||
|
clear_smtp_env()
|
||||||
|
if path = Process.get(:smtp_password_file_path), do: File.rm(path)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "SMTP_PASSWORD overrides SMTP_PASSWORD_FILE when both are set" do
|
||||||
|
path = Path.join(System.tmp_dir!(), "mv_smtp_test_#{System.unique_integer([:positive])}")
|
||||||
|
File.write!(path, "file-secret")
|
||||||
|
Process.put(:smtp_password_file_path, path)
|
||||||
|
set_smtp_env("SMTP_PASSWORD_FILE", path)
|
||||||
|
set_smtp_env("SMTP_PASSWORD", "env-wins")
|
||||||
|
assert Mv.Config.smtp_password() == "env-wins"
|
||||||
|
after
|
||||||
|
clear_smtp_env()
|
||||||
|
if path = Process.get(:smtp_password_file_path), do: File.rm(path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "smtp_*_env_set?/0" do
|
||||||
|
test "smtp_host_env_set? returns true when SMTP_HOST is set" do
|
||||||
|
set_smtp_env("SMTP_HOST", "x")
|
||||||
|
assert Mv.Config.smtp_host_env_set?() == true
|
||||||
|
after
|
||||||
|
clear_smtp_env()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "smtp_password_env_set? returns true when SMTP_PASSWORD or SMTP_PASSWORD_FILE is set" do
|
||||||
|
set_smtp_env("SMTP_PASSWORD", "x")
|
||||||
|
assert Mv.Config.smtp_password_env_set?() == true
|
||||||
|
after
|
||||||
|
clear_smtp_env()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp set_smtp_env(key, value) do
|
||||||
|
System.put_env(key, value)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp clear_smtp_env do
|
||||||
|
System.delete_env("SMTP_HOST")
|
||||||
|
System.delete_env("SMTP_PORT")
|
||||||
|
System.delete_env("SMTP_USERNAME")
|
||||||
|
System.delete_env("SMTP_PASSWORD")
|
||||||
|
System.delete_env("SMTP_PASSWORD_FILE")
|
||||||
|
System.delete_env("SMTP_SSL")
|
||||||
|
end
|
||||||
|
end
|
||||||
47
test/mv/mailer_test.exs
Normal file
47
test/mv/mailer_test.exs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
defmodule Mv.MailerTest do
|
||||||
|
@moduledoc """
|
||||||
|
Unit tests for Mv.Mailer, in particular send_test_email/1.
|
||||||
|
|
||||||
|
Uses Swoosh.Adapters.Test (configured in test.exs); no real SMTP. Asserts
|
||||||
|
success/error contract and that one test email is sent on success.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
|
import Swoosh.TestAssertions
|
||||||
|
|
||||||
|
alias Mv.Mailer
|
||||||
|
|
||||||
|
describe "send_test_email/1" do
|
||||||
|
test "returns {:ok, email} and sends one email with expected subject/body when successful" do
|
||||||
|
to_email = "test-#{System.unique_integer([:positive])}@example.com"
|
||||||
|
|
||||||
|
assert {:ok, _email} = Mailer.send_test_email(to_email)
|
||||||
|
|
||||||
|
assert_email_sent(fn email ->
|
||||||
|
to_addresses = Enum.map(email.to, &elem(&1, 1))
|
||||||
|
subject = email.subject || ""
|
||||||
|
body = email.html_body || email.text_body || ""
|
||||||
|
|
||||||
|
to_email in to_addresses and
|
||||||
|
(String.contains?(subject, "Test") or String.contains?(body, "test"))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns {:error, reason} for invalid email address" do
|
||||||
|
result = Mailer.send_test_email("not-an-email")
|
||||||
|
assert {:error, _reason} = result
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uses mail_from as sender" do
|
||||||
|
to_email = "recipient-#{System.unique_integer([:positive])}@example.com"
|
||||||
|
assert {:ok, _} = Mailer.send_test_email(to_email)
|
||||||
|
|
||||||
|
assert_email_sent(fn email ->
|
||||||
|
{_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
|
||||||
|
end
|
||||||
|
|
@ -65,4 +65,49 @@ defmodule MvWeb.GlobalSettingsLiveTest do
|
||||||
assert html =~ "must be present"
|
assert html =~ "must be present"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "SMTP / E-Mail section" do
|
||||||
|
setup %{conn: conn} do
|
||||||
|
user = create_test_user(%{email: "admin@example.com"})
|
||||||
|
conn = conn_with_oidc_user(conn, user)
|
||||||
|
{:ok, conn: conn, user: user}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders SMTP section with host/port fields and test email area", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||||
|
# Section title (Gettext key: SMTP or E-Mail per concept)
|
||||||
|
assert html =~ "SMTP" or html =~ "E-Mail"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows Send test email button when SMTP is configured", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||||
|
# When Mv.Config.smtp_configured?() is true, button and recipient input should be present
|
||||||
|
# In test env SMTP is typically not configured; we only assert the section exists
|
||||||
|
html = render(view)
|
||||||
|
assert html =~ "SMTP" or html =~ "E-Mail"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "send test email with valid address shows success or error result", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||||
|
|
||||||
|
if has_element?(view, "[data-testid='smtp-test-email-form']") do
|
||||||
|
# Submit the test-email form (phx-submit) with a valid recipient address
|
||||||
|
view
|
||||||
|
|> 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
|
||||||
|
assert render(view) =~ "Settings"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
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")
|
||||||
|
assert html =~ "SMTP" or html =~ "E-Mail" or html =~ "Settings"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -251,12 +251,10 @@ defmodule MvWeb.GroupLive.ShowTest do
|
||||||
has_element?(view, "[data-testid=group-show-members-table]", member.last_name)
|
has_element?(view, "[data-testid=group-show-members-table]", member.last_name)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
# Verify query count is reasonable (should avoid N+1 queries)
|
# Verify query count is reasonable (should avoid N+1 queries).
|
||||||
# Expected: 1 query for group lookup + 1 query for members (with preload) + member_count aggregate
|
# Baseline: group + members preload + member_count aggregate + 1 layout get_settings + auth/role/join-count.
|
||||||
# Allow overhead for authorization, LiveView setup, and other initialization queries
|
assert final_count <= 22,
|
||||||
# Note: member_count aggregate and authorization checks may add additional queries
|
"Expected max 22 queries (group + members preload + member_count + layout + auth), got #{final_count}. This suggests N+1 query problem."
|
||||||
assert final_count <= 20,
|
|
||||||
"Expected max 20 queries (group + members preload + member_count aggregate + LiveView setup + auth), got #{final_count}. This suggests N+1 query problem."
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "slug lookup is efficient (uses unique_slug index)", %{conn: conn} do
|
test "slug lookup is efficient (uses unique_slug index)", %{conn: conn} do
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ defmodule MvWeb.JoinLiveTest do
|
||||||
test "submit with valid allowlist data creates one JoinRequest and shows success copy", %{
|
test "submit with valid allowlist data creates one JoinRequest and shows success copy", %{
|
||||||
conn: conn
|
conn: conn
|
||||||
} do
|
} do
|
||||||
|
# Re-apply allowlist so this test is robust when run in parallel with others (Settings singleton).
|
||||||
|
enable_join_form_for_test(%{})
|
||||||
count_before = count_join_requests()
|
count_before = count_join_requests()
|
||||||
{:ok, view, _html} = live(conn, "/join")
|
{:ok, view, _html} = live(conn, "/join")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue