refactor(proto): model account transport as a oneof #367

Merged
KofTwentyTwo merged 1 commits from story/ws-a6-account-transport-oneof into dev 2026-07-31 23:13:13 +00:00
KofTwentyTwo commented 2026-07-31 23:00:54 +00:00 (Migrated from github.com)

Closes #278 (WS-A6). Sole proto editor this cycle; golden regenerated.

Problem

AccountConfig was a flat bag of optional transport fields, so a nonsensical mix (IMAP host + JMAP endpoint + DAV url all set) was structurally allowed. The owner decided each account has exactly ONE transport.

Design — make invalid states unrepresentable

Proto (nuncio.v1, not frozen — breaking changes OK): AccountConfig now carries

oneof transport {
  ImapSmtpTransport imap_smtp = 15;
  JmapTransport     jmap      = 16;
  DavTransport      dav       = 17;
}

Sub-messages:

  • ImapSmtpTransport { imap_host, imap_port, imap_tls_mode, smtp_host, smtp_port, smtp_tls_mode }
  • JmapTransport { endpoint_host } (JMAP is HTTPS; no cleartext TLS mode)
  • DavTransport { collection_url }

Common fields (id, name, email_address, keyring_secret_key, sync_interval) stay outside the oneof. The removed flat field numbers (4-9, 12-14) plus names are reserved. Golden descriptor regenerated; contract_stability green.

Core (nuncio-core): AccountConfig carries a Transport { ImapSmtp(..) | Jmap(..) | Dav(..) } enum. validate() checks the active variant's fields and preserves the WS-D1 cleartext-Plain-to-remote-host refusal per transport (imap + smtp legs for IMAP/SMTP; http:// collection URL to a non-loopback host for DAV). JMAP over HTTPS has no cleartext leg.

Store (nuncio-store): physical accounts columns stay flat. The existing protocol column is the transport-kind discriminator (no new column needed) — documented in save_account. save flattens the active variant; list/get and the salvage/recovery path reconstruct the exact variant. Every variant round-trips.

Daemon (nunciod/grpc.rs): maps core Transport <-> proto oneof both directions. An unset/unknown transport oneof returns typed VALIDATION_FAILED (nuncio_proto::errors, InvalidArgument), never a panic. TestAccountConnection probes only the IMAP/SMTP legs, honestly reporting N/A for JMAP/DAV.

CLI (nuncio-cli): account add --protocol imap-smtp|jmap|caldav selects the transport and gates the relevant flags (kept the existing flag UX). list/show render the active transport; --json emits protocol plus that transport's own fields. edit rejects IMAP/SMTP flags on a non-imap-smtp account.

Tests

  • New store regression: distinct_transport_accounts_round_trip_to_their_own_variant (IMAP + JMAP + DAV persist and load back as their own variant).
  • New daemon unit test: account_config_from_proto_rejects_unset_transport_as_validation_failed.
  • Core validate tests updated for the enum shape (incl. per-transport cleartext refusal); existing account/e2e tests updated.

Gate (all green, 0 ignored)

  • cargo fmt --all -- --check — clean
  • cargo check-all (clippy -D warnings) — clean
  • cargo test-all — all suites pass, 0 failed, 0 ignored

🤖 Generated with Claude Code

Closes #278 (WS-A6). Sole proto editor this cycle; golden regenerated. ## Problem `AccountConfig` was a flat bag of optional transport fields, so a nonsensical mix (IMAP host + JMAP endpoint + DAV url all set) was structurally allowed. The owner decided each account has exactly ONE transport. ## Design — make invalid states unrepresentable **Proto** (`nuncio.v1`, not frozen — breaking changes OK): `AccountConfig` now carries ``` oneof transport { ImapSmtpTransport imap_smtp = 15; JmapTransport jmap = 16; DavTransport dav = 17; } ``` Sub-messages: - `ImapSmtpTransport { imap_host, imap_port, imap_tls_mode, smtp_host, smtp_port, smtp_tls_mode }` - `JmapTransport { endpoint_host }` (JMAP is HTTPS; no cleartext TLS mode) - `DavTransport { collection_url }` Common fields (`id`, `name`, `email_address`, `keyring_secret_key`, `sync_interval`) stay outside the oneof. The removed flat field numbers (4-9, 12-14) plus names are `reserved`. Golden descriptor regenerated; `contract_stability` green. **Core** (`nuncio-core`): `AccountConfig` carries a `Transport { ImapSmtp(..) | Jmap(..) | Dav(..) }` enum. `validate()` checks the active variant's fields and preserves the WS-D1 cleartext-`Plain`-to-remote-host refusal per transport (imap + smtp legs for IMAP/SMTP; `http://` collection URL to a non-loopback host for DAV). JMAP over HTTPS has no cleartext leg. **Store** (`nuncio-store`): physical `accounts` columns stay flat. The existing `protocol` column is the transport-kind discriminator (no new column needed) — documented in `save_account`. `save` flattens the active variant; `list`/`get` and the salvage/recovery path reconstruct the exact variant. Every variant round-trips. **Daemon** (`nunciod/grpc.rs`): maps core `Transport` <-> proto oneof both directions. An unset/unknown transport oneof returns typed `VALIDATION_FAILED` (`nuncio_proto::errors`, `InvalidArgument`), never a panic. `TestAccountConnection` probes only the IMAP/SMTP legs, honestly reporting N/A for JMAP/DAV. **CLI** (`nuncio-cli`): `account add --protocol imap-smtp|jmap|caldav` selects the transport and gates the relevant flags (kept the existing flag UX). `list`/`show` render the active transport; `--json` emits `protocol` plus that transport's own fields. `edit` rejects IMAP/SMTP flags on a non-imap-smtp account. ## Tests - New store regression: `distinct_transport_accounts_round_trip_to_their_own_variant` (IMAP + JMAP + DAV persist and load back as their own variant). - New daemon unit test: `account_config_from_proto_rejects_unset_transport_as_validation_failed`. - Core `validate` tests updated for the enum shape (incl. per-transport cleartext refusal); existing account/e2e tests updated. ## Gate (all green, 0 ignored) - `cargo fmt --all -- --check` — clean - `cargo check-all` (clippy `-D warnings`) — clean - `cargo test-all` — all suites pass, 0 failed, 0 ignored 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.