feat(email-ingress): admin lifecycle UI (LC-77) #197
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/lc-77-admin-ui"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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:
feat(email-ingress): per-room admin UI for email inboxes—GET/POST /room/{id}/email-inboxesandPOST /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}/moderatorsnext to the existing "Manage incoming webhooks" link.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_domainunset: error banner names the admin settings page.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
create_email_inbox_reveals_address_onceconfirms (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 onlysecret_hash(not the token), keyed byauth::hash_api_token.end_to_end_create_then_poll_processes_real_mailcreates via the HTTP route, parses the secret out of the rendered HTML, then crafts an RFC 822 message addressed to that secret and drivesemail_ingress::poll::process_polled_message. The message posts to the room with the rightemail_inbox_idand the Subject-as-bold body. Proves the create → resolve → post chain works in practice, not just in unit tests.revoked_inbox_no_longer_accepts_polled_mailconfirms HTTP revoke flips the row, and a subsequent polled message drops withDropReason::RevokedInbox.first_save_seals_password_and_round_trips_via_readasserts the on-diskpassword_encryptedBLOB does NOT contain the plaintext substring, AND the GET render does NOT echo the plaintext. The seal is the trust boundary.Anti-scope
last_used_atcolumn is populated by the poll loop already; stats can grow if operators ask.Test plan
--features standalone(default).--no-default-features --features saas.routes_admin_imap_settings.rscarries#![cfg(feature = "standalone")]because /admin is standalone-only (matches the existingmod adminfeature gate).cargo clippy -- -D warningsclean on the workspace.cargo fmt --checkclean.routes_email_inboxes.rs(create / list / revoke / non-mod-403 / ingress-gate / end-to-end with poll / revoke-then-poll-drops), 5 inroutes_admin_imap_settings.rs(first save / preserve-on-empty / overwrite-on-nonempty / non-admin-forbidden / invalid-port).Followups (not in scope)
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>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>