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
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
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)
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 #278 (WS-A6). Sole proto editor this cycle; golden regenerated.
Problem
AccountConfigwas 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):AccountConfignow carriesSub-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 arereserved. Golden descriptor regenerated;contract_stabilitygreen.Core (
nuncio-core):AccountConfigcarries aTransport { 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): physicalaccountscolumns stay flat. The existingprotocolcolumn is the transport-kind discriminator (no new column needed) — documented insave_account.saveflattens the active variant;list/getand the salvage/recovery path reconstruct the exact variant. Every variant round-trips.Daemon (
nunciod/grpc.rs): maps coreTransport<-> proto oneof both directions. An unset/unknown transport oneof returns typedVALIDATION_FAILED(nuncio_proto::errors,InvalidArgument), never a panic.TestAccountConnectionprobes only the IMAP/SMTP legs, honestly reporting N/A for JMAP/DAV.CLI (
nuncio-cli):account add --protocol imap-smtp|jmap|caldavselects the transport and gates the relevant flags (kept the existing flag UX).list/showrender the active transport;--jsonemitsprotocolplus that transport's own fields.editrejects IMAP/SMTP flags on a non-imap-smtp account.Tests
distinct_transport_accounts_round_trip_to_their_own_variant(IMAP + JMAP + DAV persist and load back as their own variant).account_config_from_proto_rejects_unset_transport_as_validation_failed.validatetests updated for the enum shape (incl. per-transport cleartext refusal); existing account/e2e tests updated.Gate (all green, 0 ignored)
cargo fmt --all -- --check— cleancargo check-all(clippy-D warnings) — cleancargo test-all— all suites pass, 0 failed, 0 ignored🤖 Generated with Claude Code