M5: Account lifecycle over gRPC (edit/delete/test) + persist TLS mode #230

Merged
KofTwentyTwo merged 5 commits from story/m5-s1-account-lifecycle into dev 2026-07-30 01:57:56 +00:00
KofTwentyTwo commented 2026-07-30 01:41:37 +00:00 (Migrated from github.com)

Closes #194. Fixes #182.

Adds the account lifecycle (edit/delete/test) to nuncio.v1.Accounts and makes the IMAP/SMTP TLS transport mode a genuine, persisted, connection-branching setting instead of a validate-and-discard field.

Layers

proto — Additive within nuncio.v1: UpdateAccount, RemoveAccount, TestAccountConnection RPCs + their messages (UpdateAccountRequest{config, optional password}, TestAccountConnectionResponse{imap_ok, smtp_ok, optional imap_error, smtp_error}). Golden descriptor regenerated (descriptor.bin); contract_stability::descriptor_matches_committed_golden passes.

store — Backfill-safe imap_tls_mode/smtp_tls_mode column migration (ensure_accounts_tls_mode_columns, mirroring the SMTP-columns migration). save_account/list_accounts now bind+read both modes (previously hardcoded back to ImplicitTls). New get_account/delete_account. Regression test proves both distinct modes survive a save → list → get round-trip.

mail — TlsMode threaded through ImapEngine::with_credentials/ImapDualSocketManager and SmtpTransportEngine::new. IMAP now selects the transport genuinely via a unified MailStream enum:

  • ImplicitTls — immediate TLS wrap (existing path).
  • StartTls — plain TCP → read greeting → issue STARTTLS → confirm tagged OK → upgrade the same socket via the shared tokio_rustls connector (RFC 3501/2595), reusing build_tls_connector so cert validation is identical to implicit TLS.
  • Plain — bare TCP.

A bounded CONNECT_TIMEOUT prevents an unreachable host from hanging. SMTP selects the lettre (0.11) builder by mode: relay(host).tls(Tls::Wrapper) for implicit, relay(host).tls(Tls::Required) for STARTTLS, builder_dangerous(host) for plain. probe_connection on each engine backs the test RPC; SMTP uses AsyncTransport::test_connection (genuine dial + handshake; honestly does not claim isolated auth). Per-mode setup unit tests are mock/loopback only.

daemon — update_account/remove_account/test_account_connection implemented. update_account reuses the exact merged add_account keyring-then-persist rollback discipline on password rotation. test_account_connection resolves the account, reads the keyring password, and probes via an injectable AccountConnectionTester (production = real bounded IMAP+SMTP probe honoring persisted TLS modes) — genuine per-protocol result, never canned. TLS modes wired into build_mail_backend/build_smtp_sender. The service stays behind its own BearerAuthInterceptor (threaded via a new AccountsEngineOverrides).

cli — account show/edit/delete/test are now real thin gRPC clients (no self.db reads, no hardcoded success strings). edit gains --imap-mode/--smtp-mode, fetches the existing account via ListAccounts, merges only supplied flags, and sends no password (existing credential preserved).

Tests / E2E

  • Store round-trip + get/delete regression tests.
  • Mail per-mode connection-setup unit tests (loopback only).
  • CLI stub-daemon round-trip extended to cover show/edit/delete/test (+ garbage-mode rejection).
  • New offline full-daemon E2E accounts_lifecycle_e2e_test: add (StartTls/Plain) → list (modes round-trip) → update → test-connection (injected tester, IMAP ok / SMTP fail-with-detail) → remove → not-found — all over authenticated gRPC, mock-only.
  • grpc_auth_test: unauthenticated update_account/remove_account/test_account_connection each rejected with Unauthenticated.

Gate output

cargo fmt --all -- --check   -> clean
cargo check-all              -> clean (clippy --all-targets --workspace -D warnings, zero warnings)
cargo test-all               -> 409 passed; 0 failed; 0 ignored

Golden regenerated and descriptor_matches_committed_golden passes.

Notes / follow-ups

  • SMTP probe_connection verifies dial + TLS/STARTTLS negotiation, not an isolated AUTH (SMTP has no auth-only probe without submitting a message); documented honestly in code.
  • update_account mirrors add_account's rollback verbatim per the story brief: on a password rotation where the keyring key is unchanged, a persist failure deletes the just-overwritten secret rather than restoring the prior value. This is the intended mirrored behavior; a future refinement could snapshot-and-restore, but that was explicitly out of scope ("do not invent a second pattern").

No merge — for PO review.

Closes #194. Fixes #182. Adds the account lifecycle (edit/delete/test) to `nuncio.v1.Accounts` and makes the IMAP/SMTP TLS transport mode a genuine, persisted, connection-branching setting instead of a validate-and-discard field. ## Layers **proto** — Additive within `nuncio.v1`: `UpdateAccount`, `RemoveAccount`, `TestAccountConnection` RPCs + their messages (`UpdateAccountRequest{config, optional password}`, `TestAccountConnectionResponse{imap_ok, smtp_ok, optional imap_error, smtp_error}`). Golden descriptor regenerated (`descriptor.bin`); `contract_stability::descriptor_matches_committed_golden` passes. **store** — Backfill-safe `imap_tls_mode`/`smtp_tls_mode` column migration (`ensure_accounts_tls_mode_columns`, mirroring the SMTP-columns migration). `save_account`/`list_accounts` now bind+read both modes (previously hardcoded back to `ImplicitTls`). New `get_account`/`delete_account`. Regression test proves both distinct modes survive a save → list → get round-trip. **mail** — `TlsMode` threaded through `ImapEngine::with_credentials`/`ImapDualSocketManager` and `SmtpTransportEngine::new`. IMAP now selects the transport genuinely via a unified `MailStream` enum: - **ImplicitTls** — immediate TLS wrap (existing path). - **StartTls** — plain TCP → read greeting → issue `STARTTLS` → confirm tagged `OK` → upgrade the same socket via the shared `tokio_rustls` connector (RFC 3501/2595), reusing `build_tls_connector` so cert validation is identical to implicit TLS. - **Plain** — bare TCP. A bounded `CONNECT_TIMEOUT` prevents an unreachable host from hanging. SMTP selects the `lettre` (0.11) builder by mode: `relay(host).tls(Tls::Wrapper)` for implicit, `relay(host).tls(Tls::Required)` for STARTTLS, `builder_dangerous(host)` for plain. `probe_connection` on each engine backs the test RPC; SMTP uses `AsyncTransport::test_connection` (genuine dial + handshake; honestly does not claim isolated auth). Per-mode setup unit tests are mock/loopback only. **daemon** — `update_account`/`remove_account`/`test_account_connection` implemented. `update_account` reuses the exact merged `add_account` keyring-then-persist rollback discipline on password rotation. `test_account_connection` resolves the account, reads the keyring password, and probes via an injectable `AccountConnectionTester` (production = real bounded IMAP+SMTP probe honoring persisted TLS modes) — genuine per-protocol result, never canned. TLS modes wired into `build_mail_backend`/`build_smtp_sender`. The service stays behind its own `BearerAuthInterceptor` (threaded via a new `AccountsEngineOverrides`). **cli** — `account show/edit/delete/test` are now real thin gRPC clients (no `self.db` reads, no hardcoded success strings). `edit` gains `--imap-mode`/`--smtp-mode`, fetches the existing account via `ListAccounts`, merges only supplied flags, and sends no password (existing credential preserved). ## Tests / E2E - Store round-trip + get/delete regression tests. - Mail per-mode connection-setup unit tests (loopback only). - CLI stub-daemon round-trip extended to cover show/edit/delete/test (+ garbage-mode rejection). - New offline full-daemon E2E `accounts_lifecycle_e2e_test`: add (StartTls/Plain) → list (modes round-trip) → update → test-connection (injected tester, IMAP ok / SMTP fail-with-detail) → remove → not-found — all over authenticated gRPC, mock-only. - `grpc_auth_test`: unauthenticated `update_account`/`remove_account`/`test_account_connection` each rejected with `Unauthenticated`. ## Gate output ``` cargo fmt --all -- --check -> clean cargo check-all -> clean (clippy --all-targets --workspace -D warnings, zero warnings) cargo test-all -> 409 passed; 0 failed; 0 ignored ``` Golden regenerated and `descriptor_matches_committed_golden` passes. ## Notes / follow-ups - SMTP `probe_connection` verifies dial + TLS/STARTTLS negotiation, not an isolated AUTH (SMTP has no auth-only probe without submitting a message); documented honestly in code. - `update_account` mirrors `add_account`'s rollback verbatim per the story brief: on a password rotation where the keyring key is unchanged, a persist failure deletes the just-overwritten secret rather than restoring the prior value. This is the intended mirrored behavior; a future refinement could snapshot-and-restore, but that was explicitly out of scope ("do not invent a second pattern"). No merge — for PO review.
Sign in to join this conversation.