feat(settings): DB-backed SMTP email config with live mailer swap (PMS-638) #433

Merged
nrupard merged 4 commits from feat/PMS-638-email-db-settings into main 2026-07-09 17:16:56 +02:00
Owner

First slice of PMS-636 (move config from env vars into DB-backed admin settings): email/SMTP.

What

SMTP mailer config becomes editable at runtime through a DB-backed admin setting, resolved at boot and hot-swapped on change. Env stays the fallback.

  • MailerConfig::resolve (new, src/modules/settings/email.rs): env baseline (from_env) overridden per field by a single system-tenant (Uuid::from_u128(1)) row under tenant_settings category email. No DB row => identical to today's env-only behaviour.
  • smtp_password is stored AES-256-GCM-encrypted (reusing crypto::encrypt + ENCRYPTION_KEY, the same pattern as payment_gateway_configs); decrypted only inside resolve, never returned by the read API.
  • SharedMailer (new, src/utils/email.rs): a live-swappable Mailer decorator (RwLock<Arc<dyn Mailer>>). Built once at boot and distributed to every consumer (AppState, notifications worker, tickets automation, contacts service) unchanged; the admin handler swaps the inner in place, so a config change takes effect without a restart and without touching any consumer signature.
  • Typed admin endpoint (RequireAdmin): GET /api/v1/settings/email (password masked, password_set: bool) and PUT /api/v1/settings/email (validates port / from-mailbox / tls, encrypts the password, upserts, then rebuilds + swaps the live mailer).
  • main.rs resolves from DB-then-env at boot and wraps the result in SharedMailer.
  • .env.example: documents that SMTP_* is now the fallback overridden by the DB config.

Scope (per PMS-636 scoping)

Decisions taken with the issue owner: system-wide email config (default tenant), not per-tenant; app-wide mailer rebuilt in place on change; OAUTH_SUPER_ADMIN_EMAILS + bootstrap ADMIN_* deliberately KEPT as env (they gate who becomes super_admin / first-run; an env var set at deploy is not admin-editable). During scoping the codebase showed the Stripe integration is already DB-backed + encrypted (payment_gateway_configs) and the OCI registry config named in the issue does not exist in mokosh-server, so those are not part of this slice.

Access / RLS

tenant_settings is fail-closed RLS (migration 038), so all reads/writes go through begin_with_tenant(SYSTEM_TENANT), matching SettingsService. Boot resolution tolerates an absent system-tenant row (returns the env config).

Tests

Unit (DB-free, run in cargo test --lib): SharedMailer swap redirects sends to the new inner; SmtpTls round-trips through parse/as_str; password encrypt/mask (ciphertext != cleartext, decrypt recovers it, view masks it); stored JSON omits absent fields so resolve treats them as env fallbacks. The DB-backed resolve/put precedence + endpoint behaviour are integration-level (follow-up: tests/*.rs).

#PMS-638

First slice of PMS-636 (move config from env vars into DB-backed admin settings): email/SMTP. ## What SMTP mailer config becomes editable at runtime through a DB-backed admin setting, resolved at boot and hot-swapped on change. Env stays the fallback. - `MailerConfig::resolve` (new, `src/modules/settings/email.rs`): env baseline (`from_env`) overridden per field by a single system-tenant (`Uuid::from_u128(1)`) row under `tenant_settings` category `email`. No DB row => identical to today's env-only behaviour. - `smtp_password` is stored AES-256-GCM-encrypted (reusing `crypto::encrypt` + `ENCRYPTION_KEY`, the same pattern as `payment_gateway_configs`); decrypted only inside `resolve`, never returned by the read API. - `SharedMailer` (new, `src/utils/email.rs`): a live-swappable `Mailer` decorator (`RwLock<Arc<dyn Mailer>>`). Built once at boot and distributed to every consumer (AppState, notifications worker, tickets automation, contacts service) unchanged; the admin handler swaps the inner in place, so a config change takes effect without a restart and without touching any consumer signature. - Typed admin endpoint (`RequireAdmin`): `GET /api/v1/settings/email` (password masked, `password_set: bool`) and `PUT /api/v1/settings/email` (validates port / from-mailbox / tls, encrypts the password, upserts, then rebuilds + swaps the live mailer). - `main.rs` resolves from DB-then-env at boot and wraps the result in `SharedMailer`. - `.env.example`: documents that `SMTP_*` is now the fallback overridden by the DB config. ## Scope (per PMS-636 scoping) Decisions taken with the issue owner: system-wide email config (default tenant), not per-tenant; app-wide mailer rebuilt in place on change; `OAUTH_SUPER_ADMIN_EMAILS` + bootstrap `ADMIN_*` deliberately KEPT as env (they gate who becomes super_admin / first-run; an env var set at deploy is not admin-editable). During scoping the codebase showed the Stripe integration is already DB-backed + encrypted (`payment_gateway_configs`) and the OCI registry config named in the issue does not exist in mokosh-server, so those are not part of this slice. ## Access / RLS `tenant_settings` is fail-closed RLS (migration 038), so all reads/writes go through `begin_with_tenant(SYSTEM_TENANT)`, matching `SettingsService`. Boot resolution tolerates an absent system-tenant row (returns the env config). ## Tests Unit (DB-free, run in `cargo test --lib`): `SharedMailer` swap redirects sends to the new inner; `SmtpTls` round-trips through `parse`/`as_str`; password encrypt/mask (ciphertext != cleartext, decrypt recovers it, view masks it); stored JSON omits absent fields so `resolve` treats them as env fallbacks. The DB-backed `resolve`/`put` precedence + endpoint behaviour are integration-level (follow-up: `tests/*.rs`). #PMS-638
feat(settings): DB-backed SMTP email config with live mailer swap
Some checks failed
Check / fmt + clippy + build + tests (pull_request) Failing after 2m46s
Integration / integration tests (pull_request) Failing after 3m9s
E2E / Playwright against staging (pull_request) Failing after 6m12s
9040a0bffe
Move the SMTP mailer configuration out of SMTP_* env vars into a DB-backed admin setting (first slice of PMS-636). The config resolves from a single system-tenant tenant_settings row overriding the env baseline per field, so an absent field falls back to its SMTP_* var and no DB row is identical to today's env-only behaviour. The SMTP password is stored AES-256-GCM-encrypted (reusing crypto::encrypt + ENCRYPTION_KEY, the payment_gateway_configs pattern) and never returned by the read API.

A SharedMailer decorator (RwLock<Arc<dyn Mailer>>) makes the app-wide mailer swappable in place, so the admin PUT /settings/email endpoint rebuilds and swaps it live without a restart and without changing any consumer signature (notifications worker, tickets automation, contacts service, AppState all keep Arc<dyn Mailer>). Access goes through begin_with_tenant since tenant_settings is fail-closed RLS.

Scope per PMS-636: system-wide config (default tenant), not per-tenant; OAUTH_SUPER_ADMIN_EMAILS and bootstrap ADMIN_* stay env (they gate super_admin and must not be admin-editable). Stripe is already DB-backed (payment_gateway_configs) and the OCI registry config is absent from mokosh-server, so both are out of this slice.

#PMS-638
fix(settings): build-validate email config before persisting it
Some checks failed
Check / fmt + clippy + build + tests (pull_request) Failing after 54s
Integration / integration tests (pull_request) Failing after 1m16s
E2E / Playwright against staging (pull_request) Failing after 3m45s
3cc8a96a27
Self-review of the PMS-638 email settings. Two fixes on the write path:

PUT /settings/email committed the row before the mailer was built, and only tls/from were parse-checked. A config that fails to build (e.g. an unparseable host) would persist and then panic the NEXT boot, where resolve + build is `.expect`-ed in main - a settings write could brick the deployment. Now the full effective config is built (cheaply, no network) before the INSERT, so a bad value is rejected and never saved.

The env-only path treats a username with no password as a hard error; the DB override path bypassed that and silently sent unauthenticated. The override merge is now shared with resolve via `config_from_stored`, which re-applies the username-requires-password invariant, so it holds on both paths.

#PMS-638
fix(test): wrap LogMailer in SharedMailer for create_api_router
Some checks failed
Check / fmt + clippy + build + tests (pull_request) Successful in 1m22s
E2E / Playwright against staging (pull_request) Failing after 1m50s
Integration / integration tests (pull_request) Successful in 5m32s
e9e19b85e3
PMS-638 changed create_api_router's mailer parameter to Arc<SharedMailer>, but the integration-test harness still passed Arc<dyn Mailer>, breaking every tests/*.rs binary (readiness/knowledge_base/reports/...). Wrap the test LogMailer in a SharedMailer to match; tests never swap it.

The dev-server container the pre-commit hook uses does not build the tests/ integration binaries, so this slipped past local checks and only failed in CI clippy. Verified here with a full --all-targets clippy over the repo.

#PMS-638
Merge branch 'main' into feat/PMS-638-email-db-settings
All checks were successful
Check / fmt + clippy + build + tests (pull_request) Successful in 1m24s
E2E / Playwright against staging (pull_request) Successful in 35s
Create release / Gate (release-branch merges only) (pull_request) Successful in 1s
Create release / Create release from merged PR (pull_request) Has been skipped
Integration / integration tests (pull_request) Successful in 12m9s
dba0e5d4a8
nrupard scheduled this pull request to auto merge when all checks succeed 2026-07-09 17:15:17 +02:00
nrupard deleted branch feat/PMS-638-email-db-settings 2026-07-09 17:16:56 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-server!433
No description provided.