feat(admin): move email/SMTP config into DB admin settings (BUNYIP-351) #351

Merged
longjacksonle merged 2 commits from feat/BUNYIP-351-email-db-config into main 2026-07-08 20:20:23 +02:00

What

Phase 2 of BUNYIP-351: move the email / SMTP settings out of env-only vars and into a DB-persisted, admin-editable singleton, following the established tier_config / stripe_config DB-overrides-env pattern.

SMTP_HOST, SMTP_PORT, SMTP_TLS, SMTP_USERNAME, SMTP_PASSWORD, SMTP_FROM (from_email + from_name), EMAIL_ENABLED, and ADMIN_NOTIFICATION_EMAILS remain the bootstrap defaults. Once an admin edits them in the new Email settings page, the email_config row overrides them per field and applies immediately with no restart. A wiped DB (all-NULL row) falls straight back to the env defaults, so a fresh deployment boots identically.

How

  • New singleton migration email_config (id CHECK=1, nullable columns, updated_at/updated_by). Additive and immutable.
  • The SMTP password is encrypted at rest (smtp_password BYTEA + smtp_password_nonce + key_version) with the same EncryptionKeySet that guards the Stripe secrets (STRIPE_ENCRYPTION_KEY). It is never stored or returned in plaintext; the admin read API returns only a masked hint + has_smtp_password, exactly like the Stripe keys.
  • EmailConfigRow + EmailConfigResponse model; EmailConfigRepository (get + COALESCE partial update + clear_password).
  • EmailConfig::from_db_row / has_db_overrides merge (mirrors TierConfig). System-level fields (base_url, app_name) and the dev-only log_tokens gate stay env-derived; enabled is recomputed from the resolved host so the env semantics hold against DB config.
  • EmailService now holds its transport + config behind a std::sync::RwLock with a reload() that rebuilds the transport (host/port/tls/credentials are baked into it), mirroring StripeService. Every send_* path snapshots the config/transport under a short read lock, never held across .await.
  • Startup DB-override load in main.rs, resolved with the Stripe key set.
  • GET/PUT /v1/admin/email handlers, routes, and an AdminEmailConfigUpdated audit action (the password is never logged, only password_changed: bool); the PUT hot-reloads the running service.
  • bunyip-web admin settings page + nav item at /admin/email (api client, types DTO, page + save handler, mail icon).
  • .env.example documents the vars as bootstrap defaults now overridable in the admin UI.

Production safety (BUNYIP-204)

The fail-fast is preserved against DB-sourced config: the effective (DB-over-env) email config is checked at startup (panic) and on admin update (validation error) so a production deployment can never be left silently running with email disabled.

Tests

cargo fmt --check, cargo clippy --workspace --all-targets -D warnings, and cargo test --workspace --all-targets all green in the pinned rust-builder container. New unit tests cover from_db_row (env fallback, overrides, encrypted-password decrypt round-trip, out-of-range port) and the web form body builder (enabled always sent, blanks omitted, port/email validation).

Scope

Auto-ban (phase 1) is a separate PR. The leftover Stripe checkout knobs (phase 3: STRIPE_SUCCESS_URL / STRIPE_CANCEL_URL / trial days into the existing stripe_config) follow next. Stripe secrets and tier config were already DB-backed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CTpxCd1SvpE8sk82aGikkz

## What Phase 2 of BUNYIP-351: move the email / SMTP settings out of env-only vars and into a DB-persisted, admin-editable singleton, following the established `tier_config` / `stripe_config` DB-overrides-env pattern. `SMTP_HOST`, `SMTP_PORT`, `SMTP_TLS`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_FROM` (from_email + from_name), `EMAIL_ENABLED`, and `ADMIN_NOTIFICATION_EMAILS` remain the bootstrap defaults. Once an admin edits them in the new Email settings page, the `email_config` row overrides them per field and applies immediately with no restart. A wiped DB (all-NULL row) falls straight back to the env defaults, so a fresh deployment boots identically. ## How - New singleton migration `email_config` (id CHECK=1, nullable columns, `updated_at`/`updated_by`). Additive and immutable. - The SMTP password is encrypted at rest (`smtp_password` BYTEA + `smtp_password_nonce` + `key_version`) with the **same** `EncryptionKeySet` that guards the Stripe secrets (`STRIPE_ENCRYPTION_KEY`). It is never stored or returned in plaintext; the admin read API returns only a masked hint + `has_smtp_password`, exactly like the Stripe keys. - `EmailConfigRow` + `EmailConfigResponse` model; `EmailConfigRepository` (`get` + COALESCE partial `update` + `clear_password`). - `EmailConfig::from_db_row` / `has_db_overrides` merge (mirrors `TierConfig`). System-level fields (`base_url`, `app_name`) and the dev-only `log_tokens` gate stay env-derived; `enabled` is recomputed from the resolved host so the env semantics hold against DB config. - `EmailService` now holds its transport + config behind a `std::sync::RwLock` with a `reload()` that **rebuilds the transport** (host/port/tls/credentials are baked into it), mirroring `StripeService`. Every `send_*` path snapshots the config/transport under a short read lock, never held across `.await`. - Startup DB-override load in `main.rs`, resolved with the Stripe key set. - `GET`/`PUT /v1/admin/email` handlers, routes, and an `AdminEmailConfigUpdated` audit action (the password is never logged, only `password_changed: bool`); the PUT hot-reloads the running service. - bunyip-web admin settings page + nav item at `/admin/email` (api client, types DTO, page + save handler, `mail` icon). - `.env.example` documents the vars as bootstrap defaults now overridable in the admin UI. ## Production safety (BUNYIP-204) The fail-fast is preserved against DB-sourced config: the effective (DB-over-env) email config is checked at startup (panic) and on admin update (validation error) so a production deployment can never be left silently running with email disabled. ## Tests `cargo fmt --check`, `cargo clippy --workspace --all-targets -D warnings`, and `cargo test --workspace --all-targets` all green in the pinned rust-builder container. New unit tests cover `from_db_row` (env fallback, overrides, encrypted-password decrypt round-trip, out-of-range port) and the web form body builder (enabled always sent, blanks omitted, port/email validation). ## Scope Auto-ban (phase 1) is a separate PR. The leftover Stripe checkout knobs (phase 3: `STRIPE_SUCCESS_URL` / `STRIPE_CANCEL_URL` / trial days into the existing `stripe_config`) follow next. Stripe secrets and tier config were already DB-backed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01CTpxCd1SvpE8sk82aGikkz
feat(admin): move email/SMTP config into DB admin settings (BUNYIP-351)
Some checks failed
E2E / Playwright against deployment (pull_request) Successful in 39s
Check / fmt + clippy + build + tests (pull_request) Has been cancelled
8f0999f6db
Email/SMTP settings (SMTP_HOST/PORT/TLS/USERNAME/PASSWORD, SMTP_FROM, EMAIL_ENABLED, ADMIN_NOTIFICATION_EMAILS) were env-only and baked into the SMTP transport once at startup. This moves them into a DB-persisted, admin-editable singleton following the tier_config / stripe_config DB-overrides-env pattern: a new email_config table whose NULL columns fall back to the env defaults at load time, so a wiped DB still boots identically and env stays the bootstrap default.

The SMTP password is a secret and is encrypted at rest (BYTEA ciphertext + nonce + key_version) with the same EncryptionKeySet that guards the Stripe secrets (STRIPE_ENCRYPTION_KEY); it is never stored or returned in plaintext (the admin read API masks it like the Stripe keys).

EmailService now holds its transport + config behind a std::sync::RwLock with a reload() that rebuilds the transport (host/port/tls/credentials are baked into it), mirroring StripeService, so an admin edit applies immediately with no restart. System-level fields (base_url, app_name) and the dev-only log_tokens gate stay env-derived.

Production safety (BUNYIP-204) is preserved against DB config: the effective (DB-over-env) email config is checked at startup and on admin update, and the api refuses to run / rejects the update when production would be left with email disabled.

Adds the singleton migration, EmailConfigRow + EmailConfigResponse model, EmailConfigRepository (get + COALESCE partial update + clear_password), EmailConfig::from_db_row / has_db_overrides, startup DB-override load in main.rs, GET/PUT /v1/admin/email handlers with an AdminEmailConfigUpdated audit action, and the bunyip-web admin settings page + nav item at /admin/email. Unit tests cover from_db_row (env fallback, overrides, password decrypt round-trip, out-of-range port) and the web form body builder.

Phase 2 of BUNYIP-351. Auto-ban (phase 1) landed separately; the leftover Stripe checkout knobs (phase 3) follow next. Stripe secrets and tier config were already DB-backed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CTpxCd1SvpE8sk82aGikkz
Merge remote-tracking branch 'origin/main' into feat/BUNYIP-351-email-db-config
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 42s
Check / fmt + clippy + build + tests (pull_request) Successful in 18m53s
Create release / Create release from merged PR (pull_request) Has been skipped
c622200426
# Conflicts:
#	bunyip-api/src/handlers/mod.rs
#	bunyip-api/src/main.rs
#	bunyip-api/src/routes/admin.rs
#	bunyip-web/src/api/admin.rs
#	bunyip-web/src/api/types.rs
#	bunyip-web/src/handlers/admin.rs
#	bunyip-web/src/main.rs
#	crates/bunyip-domain/src/config.rs
#	crates/bunyip-domain/src/models/audit.rs
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-07-08 20:02:05 +02:00
longjacksonle deleted branch feat/BUNYIP-351-email-db-config 2026-07-08 20:20:23 +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/bunyip!351
No description provided.