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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #194. Fixes #182.
Adds the account lifecycle (edit/delete/test) to
nuncio.v1.Accountsand 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,TestAccountConnectionRPCs + 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_goldenpasses.store — Backfill-safe
imap_tls_mode/smtp_tls_modecolumn migration (ensure_accounts_tls_mode_columns, mirroring the SMTP-columns migration).save_account/list_accountsnow bind+read both modes (previously hardcoded back toImplicitTls). Newget_account/delete_account. Regression test proves both distinct modes survive a save → list → get round-trip.mail —
TlsModethreaded throughImapEngine::with_credentials/ImapDualSocketManagerandSmtpTransportEngine::new. IMAP now selects the transport genuinely via a unifiedMailStreamenum:STARTTLS→ confirm taggedOK→ upgrade the same socket via the sharedtokio_rustlsconnector (RFC 3501/2595), reusingbuild_tls_connectorso cert validation is identical to implicit TLS.A bounded
CONNECT_TIMEOUTprevents an unreachable host from hanging. SMTP selects thelettre(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_connectionon each engine backs the test RPC; SMTP usesAsyncTransport::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_connectionimplemented.update_accountreuses the exact mergedadd_accountkeyring-then-persist rollback discipline on password rotation.test_account_connectionresolves the account, reads the keyring password, and probes via an injectableAccountConnectionTester(production = real bounded IMAP+SMTP probe honoring persisted TLS modes) — genuine per-protocol result, never canned. TLS modes wired intobuild_mail_backend/build_smtp_sender. The service stays behind its ownBearerAuthInterceptor(threaded via a newAccountsEngineOverrides).cli —
account show/edit/delete/testare now real thin gRPC clients (noself.dbreads, no hardcoded success strings).editgains--imap-mode/--smtp-mode, fetches the existing account viaListAccounts, merges only supplied flags, and sends no password (existing credential preserved).Tests / 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: unauthenticatedupdate_account/remove_account/test_account_connectioneach rejected withUnauthenticated.Gate output
Golden regenerated and
descriptor_matches_committed_goldenpasses.Notes / follow-ups
probe_connectionverifies dial + TLS/STARTTLS negotiation, not an isolated AUTH (SMTP has no auth-only probe without submitting a message); documented honestly in code.update_accountmirrorsadd_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.