feat(email-ingress): admin lifecycle UI (LC-77) #197

Merged
longjacksonle merged 2 commits from feat/lc-77-admin-ui into main 2026-05-25 19:42:53 +02:00

Summary

The human-facing surface for LC-77 email-ingress. With this PR, an operator + room moderator can configure the feature end-to-end through the web UI without touching the database.

Two commits, two distinct surfaces:

  1. feat(email-ingress): per-room admin UI for email inboxesGET/POST /room/{id}/email-inboxes and POST /room/{id}/email-inboxes/{id}/revoke. Mirrors the LC-74 webhooks admin surface field-for-field (routes/webhooks.rs + views/webhooks.rs + templates/room/webhooks.html). On create, the full <token>@<ingress-domain> address is rendered ONCE in a green success banner; the plaintext token never lands in the database (only the HMAC-SHA256 hash) and never re-renders on subsequent list views. Link added under /room/{id}/moderators next to the existing "Manage incoming webhooks" link.

  2. feat(email-ingress): admin settings UI for IMAP config — new "Email ingress (IMAP poll)" section on /admin/settings. Host / port / TLS / username / password / folder / ingress domain / enabled. Password sealed via AES-256-GCM at write (db::imap_config::write, mirrors the VAPID precedent); the form follows the existing SMTP "write-only" UX (empty input preserves the existing sealed value, non-empty overwrites). The plaintext password never echoes back to the GET render.

Three gate paths the room admin can hit at create time

  • secret_key (LETS_CHAT_SECRET_KEY) unset: error banner names the env var.
  • imap_inbox_config.ingress_domain unset: error banner names the admin settings page.
  • Both set: create proceeds; the rendered address is the only place the plaintext token surfaces.

The room-admin page's "available / not available" banner explicitly tells the operator which setting is still missing, so a fresh deployment walks through "set the env var → restart → fill the admin form → save → restart → create the per-room inbox" without confusion.

Threat-model assertions pinned in tests

  • Token leakage: create_email_inbox_reveals_address_once confirms (a) the plaintext token is rendered exactly once in the create response, (b) it does NOT re-render on a subsequent list-page GET, (c) the row in the database carries only secret_hash (not the token), keyed by auth::hash_api_token.
  • End-to-end chain: end_to_end_create_then_poll_processes_real_mail creates via the HTTP route, parses the secret out of the rendered HTML, then crafts an RFC 822 message addressed to that secret and drives email_ingress::poll::process_polled_message. The message posts to the room with the right email_inbox_id and the Subject-as-bold body. Proves the create → resolve → post chain works in practice, not just in unit tests.
  • Revoked inbox: revoked_inbox_no_longer_accepts_polled_mail confirms HTTP revoke flips the row, and a subsequent polled message drops with DropReason::RevokedInbox.
  • Password-at-rest: first_save_seals_password_and_round_trips_via_read asserts the on-disk password_encrypted BLOB does NOT contain the plaintext substring, AND the GET render does NOT echo the plaintext. The seal is the trust boundary.

Anti-scope

  • No IMAP credentials test-button on the settings page. Validating connectivity requires a live IMAP server and the spawn gate's startup log already tells the operator if anything is wrong; the test-button would duplicate that surface for v1.
  • No per-inbox stats (how many mails posted, success/drop counts). The last_used_at column is populated by the poll loop already; stats can grow if operators ask.
  • No inbox display-name edit after creation. Create new + revoke old is the v1 workflow.
  • No HTML email rendering changes. Commit 5 lands the fuller MIME walk.

Test plan

  • All 52 server test binaries pass under --features standalone (default).
  • All applicable saas binaries pass under --no-default-features --features saas. routes_admin_imap_settings.rs carries #![cfg(feature = "standalone")] because /admin is standalone-only (matches the existing mod admin feature gate).
  • cargo clippy -- -D warnings clean on the workspace.
  • cargo fmt --check clean.
  • New tests in this PR: 7 in routes_email_inboxes.rs (create / list / revoke / non-mod-403 / ingress-gate / end-to-end with poll / revoke-then-poll-drops), 5 in routes_admin_imap_settings.rs (first save / preserve-on-empty / overwrite-on-nonempty / non-admin-forbidden / invalid-port).

Followups (not in scope)

  • LC-77-SMTP-SEAL, LC-77-REPLY, LC-77-MID-DEDUP, LC-77-DEAD-LETTER (tracked previously).

Commit 5 (MIME body + attachments) lands the fuller body extraction (signature stripping, attachment handling, HTML-stripped fallback) on top of this PR.

## Summary The human-facing surface for LC-77 email-ingress. With this PR, an operator + room moderator can configure the feature end-to-end through the web UI without touching the database. Two commits, two distinct surfaces: 1. **`feat(email-ingress): per-room admin UI for email inboxes`** — `GET/POST /room/{id}/email-inboxes` and `POST /room/{id}/email-inboxes/{id}/revoke`. Mirrors the LC-74 webhooks admin surface field-for-field (`routes/webhooks.rs` + `views/webhooks.rs` + `templates/room/webhooks.html`). On create, the full `<token>@<ingress-domain>` address is rendered ONCE in a green success banner; the plaintext token never lands in the database (only the HMAC-SHA256 hash) and never re-renders on subsequent list views. Link added under `/room/{id}/moderators` next to the existing "Manage incoming webhooks" link. 2. **`feat(email-ingress): admin settings UI for IMAP config`** — new "Email ingress (IMAP poll)" section on `/admin/settings`. Host / port / TLS / username / password / folder / ingress domain / enabled. Password sealed via AES-256-GCM at write (`db::imap_config::write`, mirrors the VAPID precedent); the form follows the existing SMTP "write-only" UX (empty input preserves the existing sealed value, non-empty overwrites). The plaintext password never echoes back to the GET render. ## Three gate paths the room admin can hit at create time - `secret_key` (LETS_CHAT_SECRET_KEY) unset: error banner names the env var. - `imap_inbox_config.ingress_domain` unset: error banner names the admin settings page. - Both set: create proceeds; the rendered address is the only place the plaintext token surfaces. The room-admin page's "available / not available" banner explicitly tells the operator which setting is still missing, so a fresh deployment walks through "set the env var → restart → fill the admin form → save → restart → create the per-room inbox" without confusion. ## Threat-model assertions pinned in tests - **Token leakage**: `create_email_inbox_reveals_address_once` confirms (a) the plaintext token is rendered exactly once in the create response, (b) it does NOT re-render on a subsequent list-page GET, (c) the row in the database carries only `secret_hash` (not the token), keyed by `auth::hash_api_token`. - **End-to-end chain**: `end_to_end_create_then_poll_processes_real_mail` creates via the HTTP route, parses the secret out of the rendered HTML, then crafts an RFC 822 message addressed to that secret and drives `email_ingress::poll::process_polled_message`. The message posts to the room with the right `email_inbox_id` and the Subject-as-bold body. Proves the create → resolve → post chain works in practice, not just in unit tests. - **Revoked inbox**: `revoked_inbox_no_longer_accepts_polled_mail` confirms HTTP revoke flips the row, and a subsequent polled message drops with `DropReason::RevokedInbox`. - **Password-at-rest**: `first_save_seals_password_and_round_trips_via_read` asserts the on-disk `password_encrypted` BLOB does NOT contain the plaintext substring, AND the GET render does NOT echo the plaintext. The seal is the trust boundary. ## Anti-scope - No IMAP credentials test-button on the settings page. Validating connectivity requires a live IMAP server and the spawn gate's startup log already tells the operator if anything is wrong; the test-button would duplicate that surface for v1. - No per-inbox stats (how many mails posted, success/drop counts). The `last_used_at` column is populated by the poll loop already; stats can grow if operators ask. - No inbox display-name edit after creation. Create new + revoke old is the v1 workflow. - No HTML email rendering changes. Commit 5 lands the fuller MIME walk. ## Test plan - [x] All 52 server test binaries pass under `--features standalone` (default). - [x] All applicable saas binaries pass under `--no-default-features --features saas`. `routes_admin_imap_settings.rs` carries `#![cfg(feature = "standalone")]` because /admin is standalone-only (matches the existing `mod admin` feature gate). - [x] `cargo clippy -- -D warnings` clean on the workspace. - [x] `cargo fmt --check` clean. - [x] New tests in this PR: 7 in `routes_email_inboxes.rs` (create / list / revoke / non-mod-403 / ingress-gate / end-to-end with poll / revoke-then-poll-drops), 5 in `routes_admin_imap_settings.rs` (first save / preserve-on-empty / overwrite-on-nonempty / non-admin-forbidden / invalid-port). ## Followups (not in scope) - LC-77-SMTP-SEAL, LC-77-REPLY, LC-77-MID-DEDUP, LC-77-DEAD-LETTER (tracked previously). Commit 5 (MIME body + attachments) lands the fuller body extraction (signature stripping, attachment handling, HTML-stripped fallback) on top of this PR.
GET /room/{id}/email-inboxes lists active + revoked inboxes; POST creates an inbox and reveals the full <token>@<ingress-domain> address ONCE; POST /room/{id}/email-inboxes/{inbox_id}/revoke soft-deletes via revoked_at. Mirrors the LC-74 webhooks admin surface (routes/webhooks.rs + views/webhooks.rs + templates/room/webhooks.html) field-for-field.

Three gate paths the admin can hit at the create step:
- secret_key unset: error banner names LETS_CHAT_SECRET_KEY.
- ingress_domain unset on imap_inbox_config: error banner names the admin settings page.
- Both set: create proceeds; the rendered address is the only place the plaintext token surfaces.

The plaintext token never appears in any subsequent list-page render (proven by the test) and never lands in the database (only the HMAC-SHA256 hash via auth::hash_api_token, same shape LC-74 uses).

Linked from /room/{id}/moderators alongside the existing "Manage incoming webhooks" link so an admin who knows their way around the webhook surface finds the email-ingress equivalent immediately.

7 integration tests cover create / list / revoke / non-moderator-403 / ingress-domain-gate, plus two end-to-end tests that drive the create-via-HTTP flow into email_ingress::poll::process_polled_message:
- end_to_end_create_then_poll_processes_real_mail: extracts the token from the rendered address, crafts an RFC 822 message to that token, asserts process_polled_message posts to the room with the right email_inbox_id and Subject-as-bold body.
- revoked_inbox_no_longer_accepts_polled_mail: revokes via HTTP, then proves a subsequent polled message drops with DropReason::RevokedInbox.

This is commit 4a of the LC-77 admin lifecycle work. Commit 4b adds the admin-settings IMAP config form so an operator can configure host/port/username/password/folder/ingress_domain from the web UI instead of seeding the row through the database directly.

Verification: cargo check + cargo test --test routes_email_inboxes all 7 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
feat(email-ingress): admin settings UI for IMAP config (LC-77)
All checks were successful
check-secrets / Nosey parker (push) Successful in 4s
check-secrets / Kingfisher (push) Successful in 5s
check-secrets / TruffleHog (push) Successful in 6s
check-secrets / Kingfisher (pull_request) Successful in 4s
check-secrets / Nosey parker (pull_request) Successful in 4s
check-secrets / TruffleHog (pull_request) Successful in 5s
Create release / Create release from merged PR (pull_request) Has been skipped
Check / clippy + fmt + tests (pull_request) Successful in 2m57s
70a133f4c4
Adds an "Email ingress (IMAP poll)" section to the admin /admin/settings page. The operator fills in host / port / TLS / username / password / folder / ingress domain / enabled, presses Save, and restarts the server (the spawn gate reads the row at startup, matching the LETS_CHAT_RETENTION_SWEEP_ENABLED precedent).

Password is AES-256-GCM-sealed at write time via crypto::seal under LETS_CHAT_SECRET_KEY, mirroring the VAPID precedent (db::vapid). The form follows the existing SMTP "write-only" UX: an empty password input PRESERVES the existing sealed value, a non-empty one re-seals and overwrites. The GET render never echoes the plaintext (proven by test).

New route: POST /admin/settings/imap, AdminUser-gated. Tested:
- first_save_seals_password_and_round_trips_via_read: posts a known plaintext, confirms db::imap_config::read round-trips it, asserts the on-disk BLOB does NOT contain the plaintext substring (the seal worked), asserts the GET render does NOT echo the plaintext (no leak).
- empty_password_preserves_existing_sealed_value: second save with empty password keeps the original.
- non_empty_password_overwrites_existing_sealed_value: confirms password rotation works.
- non_admin_forbidden_from_imap_settings_post: cookie-auth gate.
- invalid_port_returns_bad_request: form validation.

5 tests, all pass. `#![cfg(feature = "standalone")]` at file scope because the /admin routes are standalone-only (CLAUDE.md test-maintenance category 3, feature-gate drift).

This is commit 4b of the LC-77 admin lifecycle work. Together with 4a (per-room create / list / revoke), an operator can now configure the entire feature from the web UI: set the IMAP poll creds here, restart, then create per-room inboxes via /room/{id}/email-inboxes.

Verification: cargo check + cargo test under both default and saas feature sets all pass. cargo clippy --tests -- -D warnings clean. cargo fmt --check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
longjacksonle deleted branch feat/lc-77-admin-ui 2026-05-25 19:42:53 +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/lets-chat!197
No description provided.