diff --git a/crates/nuncio-cli/src/args.rs b/crates/nuncio-cli/src/args.rs index 77a3314..554f691 100644 --- a/crates/nuncio-cli/src/args.rs +++ b/crates/nuncio-cli/src/args.rs @@ -202,11 +202,19 @@ pub enum UpdateSubcommand { pub enum AccountSubcommand { /// List all configured mail and calendar accounts. List, - /// Add and configure a new mail account securely. + /// Add and configure a new mail or CalDAV account securely. Add { /// Email address or account username. #[arg(short, long, help = "Email address or account username")] email: String, + /// Account protocol: imap-smtp (default), jmap, or caldav. A caldav + /// account requires --collection-url and ignores the IMAP/SMTP flags. + #[arg(long, default_value = "imap-smtp", help = "Account protocol")] + protocol: String, + /// Fully-qualified CalDAV calendar-collection URL (required for a + /// caldav account; ignored for mail accounts). + #[arg(long, help = "CalDAV calendar-collection URL (caldav only)")] + collection_url: Option, /// IMAP server hostname. #[arg(long, default_value = "mail.kof22.com", help = "IMAP server hostname")] imap_host: String, @@ -569,6 +577,8 @@ mod tests { Commands::Account { action: AccountSubcommand::Add { email: "james.maes@kof22.com".to_string(), + protocol: "imap-smtp".to_string(), + collection_url: None, imap_host: "mail.kof22.com".to_string(), imap_port: 993, smtp_host: "mail.kof22.com".to_string(), diff --git a/crates/nuncio-cli/src/runner.rs b/crates/nuncio-cli/src/runner.rs index e1fbb93..f14f0de 100644 --- a/crates/nuncio-cli/src/runner.rs +++ b/crates/nuncio-cli/src/runner.rs @@ -39,6 +39,20 @@ fn map_tls_mode_to_proto(mode: nuncio_core::TlsMode) -> nuncio_proto::v1::TlsMod } } +/// Parse an `account add --protocol` string into its wire-format +/// `nuncio.v1.AccountProtocol` value. Accepts both the kebab-case +/// serialization (`imap-smtp`) and common aliases. +fn parse_account_protocol(protocol: &str) -> Result { + match protocol.to_ascii_lowercase().as_str() { + "imap-smtp" | "imap_smtp" | "imap" => Ok(nuncio_proto::v1::AccountProtocol::ImapSmtp), + "jmap" => Ok(nuncio_proto::v1::AccountProtocol::Jmap), + "caldav" | "cal-dav" | "cal_dav" => Ok(nuncio_proto::v1::AccountProtocol::Caldav), + other => Err(format!( + "invalid protocol '{other}' (expected imap-smtp, jmap, or caldav)" + )), + } +} + /// Renders a `nuncio.v1.Message` (as returned by the daemon's `Mail` gRPC /// service) into the JSON shape used by `mail list`/`mail read`'s /// `--json` output. @@ -270,6 +284,8 @@ impl HeadlessRunner { AccountSubcommand::List => self.handle_accounts_list(json_mode).await, AccountSubcommand::Add { email, + protocol, + collection_url, imap_host, imap_port, smtp_host, @@ -280,6 +296,8 @@ impl HeadlessRunner { } => { self.handle_add_account( email, + protocol, + collection_url.as_deref(), imap_host, *imap_port, smtp_host, @@ -1603,6 +1621,8 @@ impl HeadlessRunner { async fn handle_add_account( &self, email: &str, + protocol: &str, + collection_url: Option<&str>, imap_host: &str, imap_port: u16, smtp_host: &str, @@ -1621,23 +1641,43 @@ impl HeadlessRunner { Err(e) => return Self::render_error(&e, json_mode), }; + let account_protocol = match parse_account_protocol(protocol) { + Ok(p) => p, + Err(e) => return Self::render_error(&e, json_mode), + }; + let is_caldav = account_protocol == nuncio_proto::v1::AccountProtocol::Caldav; + if is_caldav && collection_url.map(str::trim).unwrap_or_default().is_empty() { + return Self::render_error("a caldav account requires --collection-url", json_mode); + } + let keyring_key = format!("nuncio/{}", email); let account_id = format!("acct-{}", email.replace('@', "-at-").replace('.', "-")); + // A CalDAV account is addressed by its collection URL, not by mail + // host/port endpoints, so those are left empty for it. let proto_config = nuncio_proto::v1::AccountConfig { id: account_id.clone(), name: email.to_string(), email_address: email.to_string(), - protocol: nuncio_proto::v1::AccountProtocol::ImapSmtp.into(), - server_host: imap_host.to_string(), - server_port: u32::from(imap_port), + protocol: account_protocol.into(), + server_host: if is_caldav { + String::new() + } else { + imap_host.to_string() + }, + server_port: if is_caldav { 0 } else { u32::from(imap_port) }, use_tls: true, imap_tls_mode: map_tls_mode_to_proto(imap_tls_mode).into(), smtp_tls_mode: map_tls_mode_to_proto(smtp_tls_mode).into(), keyring_secret_key: keyring_key.clone(), sync_interval_secs: 300, - smtp_host: smtp_host.to_string(), - smtp_port: u32::from(smtp_port), + smtp_host: if is_caldav { + String::new() + } else { + smtp_host.to_string() + }, + smtp_port: if is_caldav { 0 } else { u32::from(smtp_port) }, + collection_url: collection_url.unwrap_or_default().to_string(), }; let mut client = match self.connect_accounts_client().await { @@ -1654,7 +1694,23 @@ impl HeadlessRunner { { Ok(response) => { let account_id = response.into_inner().id; - if json_mode { + if is_caldav { + let collection = collection_url.unwrap_or_default(); + if json_mode { + format_json(&json!({ + "configured": true, + "account_id": account_id, + "email": email, + "protocol": "caldav", + "collection_url": collection, + "keyring_key": keyring_key + })) + } else { + format!( + "CalDAV account '{email}' (ID: {account_id}) added via nunciod daemon with collection URL {collection}" + ) + } + } else if json_mode { format_json(&json!({ "configured": true, "account_id": account_id, @@ -1716,6 +1772,7 @@ impl HeadlessRunner { "smtp_tls_mode": a.smtp_tls_mode().as_str_name(), "keyring_secret_key": a.keyring_secret_key, "sync_interval_secs": a.sync_interval_secs, + "collection_url": a.collection_url, }) }) .collect(); @@ -1726,17 +1783,24 @@ impl HeadlessRunner { accounts.len() ); for a in &accounts { - out.push_str(&format!( - "\n [{}] {} <{}> IMAP {}:{} SMTP {}:{} TLS={}", - a.id, - a.name, - a.email_address, - a.server_host, - a.server_port, - a.smtp_host, - a.smtp_port, - a.use_tls, - )); + if a.protocol() == nuncio_proto::v1::AccountProtocol::Caldav { + out.push_str(&format!( + "\n [{}] {} <{}> CalDAV {}", + a.id, a.name, a.email_address, a.collection_url, + )); + } else { + out.push_str(&format!( + "\n [{}] {} <{}> IMAP {}:{} SMTP {}:{} TLS={}", + a.id, + a.name, + a.email_address, + a.server_host, + a.server_port, + a.smtp_host, + a.smtp_port, + a.use_tls, + )); + } } out } @@ -2413,6 +2477,62 @@ mod tests { ); } + #[test] + fn parse_account_protocol_accepts_known_names_and_rejects_garbage() { + assert_eq!( + parse_account_protocol("imap-smtp").expect("valid"), + nuncio_proto::v1::AccountProtocol::ImapSmtp + ); + assert_eq!( + parse_account_protocol("jmap").expect("valid"), + nuncio_proto::v1::AccountProtocol::Jmap + ); + assert_eq!( + parse_account_protocol("caldav").expect("valid"), + nuncio_proto::v1::AccountProtocol::Caldav + ); + assert_eq!( + parse_account_protocol("CalDAV").expect("case-insensitive"), + nuncio_proto::v1::AccountProtocol::Caldav + ); + let err = parse_account_protocol("smoke-signals").expect_err("garbage rejected"); + assert!(err.contains("invalid protocol")); + } + + /// `account add --protocol caldav` with no `--collection-url` must be + /// rejected BEFORE dialing the daemon -- proven by pointing at an address + /// nothing is listening on and confirming the failure is the validation + /// error, not a connection error. + #[tokio::test] + async fn account_add_caldav_requires_collection_url_before_dialing() { + let runner = + HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), "127.0.0.1:1".into()) + .await + .expect("runner init"); + + let out = runner + .execute_command( + &Commands::Account { + action: AccountSubcommand::Add { + email: "cal@nuncio.mx".to_string(), + protocol: "caldav".to_string(), + collection_url: None, + imap_host: "unused".to_string(), + imap_port: 993, + smtp_host: "unused".to_string(), + smtp_port: 465, + imap_mode: "implicit_tls".to_string(), + smtp_mode: "implicit_tls".to_string(), + password: crate::args::PasswordArg("pw".to_string()), + }, + }, + false, + ) + .await; + assert!(out.contains("requires --collection-url")); + assert!(!out.contains("unreachable")); + } + /// `account add` must reject an invalid `--imap-mode`/`--smtp-mode` /// string BEFORE ever dialing the daemon -- proven here by pointing at /// an address nothing is listening on and confirming the failure is the @@ -2429,6 +2549,8 @@ mod tests { &Commands::Account { action: AccountSubcommand::Add { email: "x@y.com".to_string(), + protocol: "imap-smtp".to_string(), + collection_url: None, imap_host: "imap.y.com".to_string(), imap_port: 993, smtp_host: "smtp.y.com".to_string(), @@ -2449,6 +2571,8 @@ mod tests { &Commands::Account { action: AccountSubcommand::Add { email: "x@y.com".to_string(), + protocol: "imap-smtp".to_string(), + collection_url: None, imap_host: "imap.y.com".to_string(), imap_port: 993, smtp_host: "smtp.y.com".to_string(), @@ -2690,6 +2814,7 @@ mod tests { sync_interval_secs: 300, smtp_host: "smtp.nuncio.mx".to_string(), smtp_port: 465, + collection_url: String::new(), }], })) } @@ -2719,6 +2844,8 @@ mod tests { &Commands::Account { action: AccountSubcommand::Add { email: "james.maes@kof22.com".to_string(), + protocol: "imap-smtp".to_string(), + collection_url: None, imap_host: "mail.kof22.com".to_string(), imap_port: 993, smtp_host: "mail.kof22.com".to_string(), diff --git a/crates/nuncio-cli/tests/cli_system_test.rs b/crates/nuncio-cli/tests/cli_system_test.rs index a691156..3994ee3 100644 --- a/crates/nuncio-cli/tests/cli_system_test.rs +++ b/crates/nuncio-cli/tests/cli_system_test.rs @@ -116,6 +116,8 @@ async fn account_add_and_list_report_honest_errors_when_daemon_unreachable() { &Commands::Account { action: AccountSubcommand::Add { email: "test@nuncio.mx".to_string(), + protocol: "imap-smtp".to_string(), + collection_url: None, imap_host: "mail.nuncio.mx".to_string(), imap_port: 993, smtp_host: "mail.nuncio.mx".to_string(), diff --git a/crates/nuncio-core/src/config.rs b/crates/nuncio-core/src/config.rs index 7ae07cf..5cbb012 100644 --- a/crates/nuncio-core/src/config.rs +++ b/crates/nuncio-core/src/config.rs @@ -11,6 +11,21 @@ pub enum AccountProtocol { Jmap, /// Legacy IMAP4rev1 + SMTP protocol engines. ImapSmtp, + /// CalDAV (RFC 4791) calendar-collection protocol engine. A CalDAV + /// account is a standalone account entry addressed by its + /// `collection_url` and its own keyring credential; it does not use the + /// IMAP/JMAP/SMTP mail endpoints. + CalDav, +} + +impl AccountProtocol { + /// Whether this protocol speaks WebDAV against a collection URL (as + /// opposed to a mail protocol addressed by host/port). Such accounts + /// require [`AccountConfig::collection_url`] and do not require the + /// IMAP/SMTP mail endpoint fields. + pub fn is_dav(self) -> bool { + matches!(self, AccountProtocol::CalDav) + } } /// Security and transport encryption mode for mail protocol streams (IMAP & SMTP). @@ -43,6 +58,9 @@ pub enum ConfigError { /// The synchronization interval is too short (minimum 10 seconds). #[error("sync interval must be at least 10 seconds")] SyncIntervalTooShort, + /// A DAV-protocol account's collection URL is not a valid http(s) URL. + #[error("collection_url must be a valid http(s) URL")] + InvalidCollectionUrl, } /// Complete configuration schema for a mail & calendar account. @@ -76,6 +94,12 @@ pub struct AccountConfig { pub keyring_secret_key: String, /// Background sync polling interval in seconds (minimum 10s). pub sync_interval_secs: u64, + /// Fully-qualified collection URL for DAV-protocol accounts (CalDAV + /// today, CardDAV later). Set for accounts whose protocol speaks WebDAV; + /// empty for mail accounts. This is a full URL (scheme + host + path), + /// not a bare host, because a DAV client dispatches requests directly at + /// the collection URL. + pub collection_url: String, } impl AccountConfig { @@ -103,19 +127,34 @@ impl AccountConfig { { return Err(ConfigError::InvalidEmailFormat); } - if self.server_host.trim().is_empty() { - return Err(ConfigError::EmptyField { - field: "server_host", - }); - } - if self.server_port == 0 { - return Err(ConfigError::InvalidPort); - } - if self.smtp_host.trim().is_empty() { - return Err(ConfigError::EmptyField { field: "smtp_host" }); - } - if self.smtp_port == 0 { - return Err(ConfigError::InvalidPort); + if self.protocol.is_dav() { + // A DAV-protocol account is addressed by its collection URL, not + // by mail host/port endpoints, so it validates the URL instead of + // the IMAP/SMTP fields (which are left empty for such accounts). + let url = self.collection_url.trim(); + if url.is_empty() { + return Err(ConfigError::EmptyField { + field: "collection_url", + }); + } + if !(url.starts_with("http://") || url.starts_with("https://")) { + return Err(ConfigError::InvalidCollectionUrl); + } + } else { + if self.server_host.trim().is_empty() { + return Err(ConfigError::EmptyField { + field: "server_host", + }); + } + if self.server_port == 0 { + return Err(ConfigError::InvalidPort); + } + if self.smtp_host.trim().is_empty() { + return Err(ConfigError::EmptyField { field: "smtp_host" }); + } + if self.smtp_port == 0 { + return Err(ConfigError::InvalidPort); + } } if self.keyring_secret_key.trim().is_empty() { return Err(ConfigError::EmptyField { @@ -149,9 +188,64 @@ mod tests { smtp_tls_mode: TlsMode::ImplicitTls, keyring_secret_key: "nuncio/acct-123".to_string(), sync_interval_secs: 60, + collection_url: String::new(), } } + fn valid_caldav_account() -> AccountConfig { + AccountConfig { + id: "acct-cal-1".to_string(), + name: "Work Calendar".to_string(), + email_address: "user@nuncio.mx".to_string(), + protocol: AccountProtocol::CalDav, + server_host: String::new(), + server_port: 0, + smtp_host: String::new(), + smtp_port: 0, + use_tls: true, + imap_tls_mode: TlsMode::ImplicitTls, + smtp_tls_mode: TlsMode::ImplicitTls, + keyring_secret_key: "nuncio/acct-cal-1".to_string(), + sync_interval_secs: 300, + collection_url: "https://caldav.example.com/calendars/user/work/".to_string(), + } + } + + #[test] + fn valid_caldav_account_passes_without_mail_endpoints() { + let config = valid_caldav_account(); + assert!(config.validate().is_ok()); + } + + #[test] + fn caldav_account_requires_collection_url() { + let mut config = valid_caldav_account(); + config.collection_url = " ".to_string(); + assert_eq!( + config.validate().unwrap_err(), + ConfigError::EmptyField { + field: "collection_url" + } + ); + } + + #[test] + fn caldav_account_rejects_non_http_collection_url() { + let mut config = valid_caldav_account(); + config.collection_url = "ftp://caldav.example.com/work/".to_string(); + assert_eq!( + config.validate().unwrap_err(), + ConfigError::InvalidCollectionUrl + ); + } + + #[test] + fn account_protocol_is_dav_only_for_dav_protocols() { + assert!(AccountProtocol::CalDav.is_dav()); + assert!(!AccountProtocol::Jmap.is_dav()); + assert!(!AccountProtocol::ImapSmtp.is_dav()); + } + #[test] fn valid_account_config_passes_validation() { let config = valid_account(); @@ -275,6 +369,11 @@ mod tests { assert_eq!(imap_json, "\"imap-smtp\""); let parsed_imap: AccountProtocol = serde_json::from_str(&imap_json).unwrap(); assert_eq!(parsed_imap, AccountProtocol::ImapSmtp); + + let caldav_json = serde_json::to_string(&AccountProtocol::CalDav).unwrap(); + assert_eq!(caldav_json, "\"cal-dav\""); + let parsed_caldav: AccountProtocol = serde_json::from_str(&caldav_json).unwrap(); + assert_eq!(parsed_caldav, AccountProtocol::CalDav); } #[test] diff --git a/crates/nuncio-proto/proto/nuncio/v1/descriptor.bin b/crates/nuncio-proto/proto/nuncio/v1/descriptor.bin index 609afaa..1f17b04 100644 Binary files a/crates/nuncio-proto/proto/nuncio/v1/descriptor.bin and b/crates/nuncio-proto/proto/nuncio/v1/descriptor.bin differ diff --git a/crates/nuncio-proto/proto/nuncio/v1/nuncio.proto b/crates/nuncio-proto/proto/nuncio/v1/nuncio.proto index fb96d27..f120980 100644 --- a/crates/nuncio-proto/proto/nuncio/v1/nuncio.proto +++ b/crates/nuncio-proto/proto/nuncio/v1/nuncio.proto @@ -175,6 +175,10 @@ enum AccountProtocol { ACCOUNT_PROTOCOL_UNSPECIFIED = 0; ACCOUNT_PROTOCOL_JMAP = 1; ACCOUNT_PROTOCOL_IMAP_SMTP = 2; + // CalDAV (RFC 4791) calendar-collection account. A CalDAV account is its + // own first-class account entry: it carries `collection_url` and its own + // keyring credential, and does not require the IMAP/SMTP mail endpoints. + ACCOUNT_PROTOCOL_CALDAV = 3; } // Mirrors `nuncio_core::TlsMode`. @@ -218,6 +222,12 @@ message AccountConfig { string smtp_host = 12; // SMTP server port number (1..=65535), used for outbound mail. uint32 smtp_port = 13; + // Fully-qualified collection URL for DAV-protocol accounts (e.g. a CalDAV + // calendar collection or, later, a CardDAV address-book collection). Set + // for accounts whose protocol speaks WebDAV; empty for mail accounts. This + // is a full URL (scheme + host + path), not a bare host, because a DAV + // client dispatches requests directly at the collection URL. + string collection_url = 14; } // AddAccountRequest carries the full account configuration plus the raw diff --git a/crates/nuncio-store/src/db.rs b/crates/nuncio-store/src/db.rs index d42a051..a40fd63 100644 --- a/crates/nuncio-store/src/db.rs +++ b/crates/nuncio-store/src/db.rs @@ -503,7 +503,8 @@ impl DatabaseEngine { smtp_host TEXT, smtp_port INTEGER, imap_tls_mode TEXT NOT NULL DEFAULT 'implicit_tls', - smtp_tls_mode TEXT NOT NULL DEFAULT 'implicit_tls' + smtp_tls_mode TEXT NOT NULL DEFAULT 'implicit_tls', + collection_url TEXT ); CREATE TABLE IF NOT EXISTS filter_rules ( @@ -709,6 +710,7 @@ impl DatabaseEngine { self.ensure_accounts_smtp_columns().await?; self.ensure_accounts_tls_mode_columns().await?; + self.ensure_accounts_dav_columns().await?; self.backfill_message_fts().await?; Ok(()) @@ -795,6 +797,37 @@ impl DatabaseEngine { Ok(()) } + /// Additive, backfill-safe migration that adds the `collection_url` + /// column to a pre-existing `accounts` table that predates DAV-protocol + /// (CalDAV/CardDAV) accounts. + /// + /// A fresh database already gets this column from `CREATE TABLE IF NOT + /// EXISTS accounts` above, so on a fresh database this is a no-op. For a + /// pre-existing database file the column is added as a `NULL`-able column, + /// so every existing (mail) row keeps loading successfully with an empty + /// collection URL (see [`Self::list_accounts`], which maps a `NULL` + /// `collection_url` to an empty string). SQLite has no `ADD COLUMN IF NOT + /// EXISTS`, so column presence is checked explicitly via `PRAGMA + /// table_info` first, making this safe to run on every daemon startup. + async fn ensure_accounts_dav_columns(&self) -> Result<(), DatabaseError> { + let existing_columns: Vec = sqlx::query("PRAGMA table_info(accounts)") + .fetch_all(&self.pool) + .await + .map_err(DatabaseError::Query)? + .iter() + .map(|row| row.get::("name")) + .collect(); + + if !existing_columns.iter().any(|c| c == "collection_url") { + sqlx::query("ALTER TABLE accounts ADD COLUMN collection_url TEXT") + .execute(&self.pool) + .await + .map_err(DatabaseError::Query)?; + } + + Ok(()) + } + /// Backfill `messages_fts` for any `messages` row that does not yet have a matching FTS /// entry -- e.g. rows written before the FTS5 index existed, or written directly by a /// process that bypassed `save_email`'s explicit index population. Run automatically as @@ -846,8 +879,8 @@ impl DatabaseEngine { sqlx::query( r#" INSERT OR REPLACE INTO accounts - (id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs, smtp_host, smtp_port, imap_tls_mode, smtp_tls_mode) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs, smtp_host, smtp_port, imap_tls_mode, smtp_tls_mode, collection_url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "#, ) .bind(&config.id) @@ -863,6 +896,7 @@ impl DatabaseEngine { .bind(config.smtp_port as i64) .bind(tls_mode_to_db(config.imap_tls_mode)) .bind(tls_mode_to_db(config.smtp_tls_mode)) + .bind(&config.collection_url) .execute(&self.pool) .await .map_err(DatabaseError::Query)?; @@ -908,9 +942,10 @@ impl DatabaseEngine { Option, String, String, + Option, )> = sqlx::query_as( r#" - SELECT id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs, smtp_host, smtp_port, imap_tls_mode, smtp_tls_mode + SELECT id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs, smtp_host, smtp_port, imap_tls_mode, smtp_tls_mode, collection_url FROM accounts "#, ) @@ -935,6 +970,7 @@ impl DatabaseEngine { smtp_port, imap_tls_mode, smtp_tls_mode, + collection_url, )| { let protocol = serde_json::from_str(&protocol_str) .unwrap_or(nuncio_core::AccountProtocol::ImapSmtp); @@ -963,6 +999,7 @@ impl DatabaseEngine { smtp_tls_mode: tls_mode_from_db(&smtp_tls_mode), keyring_secret_key, sync_interval_secs: sync_interval_secs as u64, + collection_url: collection_url.unwrap_or_default(), } }, ) @@ -2280,6 +2317,7 @@ mod tests { smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: "nuncio/acct-contended-1".to_string(), sync_interval_secs: 60, + collection_url: String::new(), }; engine.save_account(&acct).await.unwrap(); engine.close().await; @@ -2853,6 +2891,7 @@ mod tests { smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: "nuncio/acct-test-1".to_string(), sync_interval_secs: 60, + collection_url: String::new(), }; engine @@ -2896,6 +2935,7 @@ mod tests { smtp_tls_mode: nuncio_core::TlsMode::Plain, keyring_secret_key: "nuncio/acct-tls-1".to_string(), sync_interval_secs: 60, + collection_url: String::new(), }; engine.save_account(&acct).await.expect("save succeeds"); @@ -2915,6 +2955,44 @@ mod tests { assert_eq!(fetched, acct); } + /// A CalDAV account's `collection_url` must survive save/reload, and a + /// mail account's empty `collection_url` must round-trip as empty (not + /// NULL-mangled), proving the backfill-safe column persists both. + #[tokio::test] + async fn caldav_collection_url_survives_save_and_reload() { + let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap(); + + let caldav = nuncio_core::AccountConfig { + id: "acct-caldav-store-1".to_string(), + name: "Work Calendar".to_string(), + email_address: "cal@nuncio.mx".to_string(), + protocol: nuncio_core::AccountProtocol::CalDav, + server_host: String::new(), + server_port: 0, + smtp_host: String::new(), + smtp_port: 0, + use_tls: true, + imap_tls_mode: nuncio_core::TlsMode::ImplicitTls, + smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, + keyring_secret_key: "nuncio/acct-caldav-store-1".to_string(), + sync_interval_secs: 300, + collection_url: "https://dav.example.com/calendars/user/work/".to_string(), + }; + engine.save_account(&caldav).await.expect("save succeeds"); + + let fetched = engine + .get_account("acct-caldav-store-1") + .await + .expect("get succeeds") + .expect("account present"); + assert_eq!(fetched, caldav); + assert_eq!(fetched.protocol, nuncio_core::AccountProtocol::CalDav); + assert_eq!( + fetched.collection_url, + "https://dav.example.com/calendars/user/work/" + ); + } + /// Proves `get_account` resolves a saved account by id and returns `None` /// for an unknown id, and that `delete_account` genuinely removes the row /// (and is idempotent for an already-absent id). @@ -2936,6 +3014,7 @@ mod tests { smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: "nuncio/acct-del-1".to_string(), sync_interval_secs: 60, + collection_url: String::new(), }; engine.save_account(&acct).await.expect("save succeeds"); @@ -3127,6 +3206,7 @@ mod tests { smtp_tls_mode: nuncio_core::TlsMode::StartTls, keyring_secret_key: "nuncio/acct-post-migration".to_string(), sync_interval_secs: 60, + collection_url: String::new(), }; engine .save_account(&new_acct) diff --git a/crates/nuncio-store/src/recovery.rs b/crates/nuncio-store/src/recovery.rs index e400a94..6bc97ef 100644 --- a/crates/nuncio-store/src/recovery.rs +++ b/crates/nuncio-store/src/recovery.rs @@ -25,6 +25,7 @@ type SalvagedAccountRow = ( Option, String, String, + Option, ); /// Summary report of database self-healing recovery output. @@ -383,7 +384,7 @@ impl SqliteRecoveryEngine { async fn salvage_accounts(pool: &sqlx::SqlitePool) -> Vec { let rows: Result, _> = sqlx::query_as( - "SELECT id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs, smtp_host, smtp_port, imap_tls_mode, smtp_tls_mode FROM accounts" + "SELECT id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs, smtp_host, smtp_port, imap_tls_mode, smtp_tls_mode, collection_url FROM accounts" ) .fetch_all(pool) .await; @@ -406,6 +407,7 @@ impl SqliteRecoveryEngine { smtp_port, imap_tls_mode_raw, smtp_tls_mode_raw, + collection_url, )| { let protocol = serde_json::from_str(&protocol_str) .unwrap_or(nuncio_core::AccountProtocol::ImapSmtp); @@ -436,6 +438,7 @@ impl SqliteRecoveryEngine { smtp_tls_mode, keyring_secret_key, sync_interval_secs: sync_interval_secs as u64, + collection_url: collection_url.unwrap_or_default(), } }, ) @@ -608,6 +611,7 @@ mod tests { smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: "nuncio/acct-test-1".to_string(), sync_interval_secs: 60, + collection_url: String::new(), }; engine.save_account(&acct).await.unwrap(); assert!(engine.check_integrity().await.unwrap()); @@ -669,6 +673,7 @@ mod tests { smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: "nuncio/acct-salvage-1".to_string(), sync_interval_secs: 60, + collection_url: String::new(), }; engine.save_account(&acct).await.unwrap(); @@ -806,6 +811,7 @@ mod tests { smtp_tls_mode: nuncio_core::TlsMode::Plain, keyring_secret_key: "nuncio/acct-starttls-1".to_string(), sync_interval_secs: 60, + collection_url: String::new(), }; engine.save_account(&acct).await.unwrap(); } diff --git a/crates/nunciod/src/calendar_sync.rs b/crates/nunciod/src/calendar_sync.rs index 29618ac..0eb7f3d 100644 --- a/crates/nunciod/src/calendar_sync.rs +++ b/crates/nunciod/src/calendar_sync.rs @@ -4,17 +4,16 @@ //! fetches events from a [`CalendarBackend`] for a single calendar collection and //! time window, then persists each event via [`DatabaseEngine::save_calendar_event`]. //! -//! # No production entry point yet +//! # Production entry point //! -//! Unlike `sync.rs`'s `run_account_sync`/`run_all_accounts_sync`, this module -//! deliberately has NO production entry point that resolves a real account's -//! CalDAV collection URL and credential and builds a real `CalDavClient` from -//! them: `nuncio_core::AccountConfig` carries no CalDAV fields today, so there -//! is nothing persisted to build one from. [`sync_with_backend`] is the only -//! function here; it is exercised directly in tests with `MockCalendarBackend`, -//! and it is the same seam a future story's production entry point will call -//! once per-account CalDAV configuration exists. -use nuncio_cal::CalendarBackend; +//! [`sync_caldav_account`] is the production entry point: it builds a real +//! [`nuncio_cal::CalDavClient`] from a persisted [`nuncio_core::AccountConfig`] +//! (its `collection_url`) plus the credential resolved from the OS keyring, +//! then delegates to [`sync_with_backend`]. [`sync_with_backend`] is the +//! shared fetch-and-persist seam that both the production client and the +//! test-only `MockCalendarBackend` flow through, mirroring `sync.rs`'s +//! `fetch_and_persist`. +use nuncio_cal::{CalDavAccountConfig, CalDavClient, CalendarBackend}; use nuncio_store::db::DatabaseError; use thiserror::Error; @@ -55,6 +54,33 @@ pub async fn sync_with_backend( Ok(synced) } +/// Production entry point: build a real [`CalDavClient`] for `account` from its +/// persisted `collection_url` and the `password` resolved from the OS keyring +/// vault, then fetch-and-persist `calendar_id`'s events in `[start_window, +/// end_window]` via [`sync_with_backend`]. Returns the number of events synced. +/// +/// `account.collection_url` must be a fully-qualified CalDAV calendar +/// collection URL (validated at `AddAccount` time); the account's +/// `email_address` is used as the DAV basic-auth username. No fabricated data +/// is ever returned -- a transport or persistence failure surfaces as a +/// [`CalendarSyncError`]. +pub async fn sync_caldav_account( + db: &nuncio_store::db::DatabaseEngine, + account: &nuncio_core::AccountConfig, + password: &str, + calendar_id: &str, + start_window: i64, + end_window: i64, +) -> Result { + let client = CalDavClient::new(CalDavAccountConfig { + account_id: account.id.clone(), + caldav_url: account.collection_url.clone(), + username: account.email_address.clone(), + auth_token: password.to_string(), + }); + sync_with_backend(db, &client, calendar_id, start_window, end_window).await +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/nunciod/src/grpc.rs b/crates/nunciod/src/grpc.rs index b820424..23f0f66 100644 --- a/crates/nunciod/src/grpc.rs +++ b/crates/nunciod/src/grpc.rs @@ -223,6 +223,7 @@ fn map_account_protocol_to_proto(protocol: nuncio_core::AccountProtocol) -> Acco match protocol { nuncio_core::AccountProtocol::Jmap => AccountProtocolProto::Jmap, nuncio_core::AccountProtocol::ImapSmtp => AccountProtocolProto::ImapSmtp, + nuncio_core::AccountProtocol::CalDav => AccountProtocolProto::Caldav, } } @@ -236,6 +237,7 @@ fn map_account_protocol_from_proto( match protocol { AccountProtocolProto::Jmap => Ok(nuncio_core::AccountProtocol::Jmap), AccountProtocolProto::ImapSmtp => Ok(nuncio_core::AccountProtocol::ImapSmtp), + AccountProtocolProto::Caldav => Ok(nuncio_core::AccountProtocol::CalDav), AccountProtocolProto::Unspecified => { Err(Status::invalid_argument("account protocol is required")) } @@ -284,6 +286,7 @@ fn map_account_config_to_proto(config: nuncio_core::AccountConfig) -> AccountCon sync_interval_secs: config.sync_interval_secs, smtp_host: config.smtp_host, smtp_port: u32::from(config.smtp_port), + collection_url: config.collection_url, } } @@ -315,6 +318,7 @@ fn map_account_config_from_proto( smtp_tls_mode, keyring_secret_key: config.keyring_secret_key, sync_interval_secs: config.sync_interval_secs, + collection_url: config.collection_url, }) } @@ -405,8 +409,22 @@ impl AccountConnectionTester for RealAccountConnectionTester { nuncio_core::AccountProtocol::Jmap => ProtocolProbe::failed( "IMAP connection probe is not applicable to a JMAP account".to_string(), ), + nuncio_core::AccountProtocol::CalDav => ProtocolProbe::failed( + "IMAP connection probe is not applicable to a CalDAV account".to_string(), + ), }; + // A CalDAV account has no SMTP endpoint to probe; report that honestly + // rather than dialing the (empty) mail endpoint fields. + if config.protocol == nuncio_core::AccountProtocol::CalDav { + return AccountConnectionReport { + imap, + smtp: ProtocolProbe::failed( + "SMTP connection probe is not applicable to a CalDAV account".to_string(), + ), + }; + } + let smtp = match SmtpTransportEngine::new( &config.smtp_host, config.smtp_port, @@ -872,15 +890,16 @@ fn expand_events_for_window( /// /// Production (every existing caller of [`serve`] / [`serve_on_listener`]) /// never constructs a non-default instance: [`Default`] yields -/// `calendar_backend: None`, which routes `Calendar/Sync` to an honest -/// error, since there is currently no persisted per-account CalDAV -/// configuration to build a real backend from. Only a full-daemon E2E test -/// supplies `Some(..)`, standing in a [`nuncio_cal::MockCalendarBackend`] -/// for real network I/O. +/// `calendar_backend: None`, which routes `Calendar/Sync` through the real +/// production path -- resolving configured CalDAV accounts, building a real +/// [`nuncio_cal::CalDavClient`] from each account's collection URL and +/// keyring credential, and syncing genuinely fetched events. Only a +/// full-daemon E2E test that wants to bypass the real network transport +/// supplies `Some(..)`, standing in a [`nuncio_cal::MockCalendarBackend`]. #[derive(Clone, Default)] pub struct CalendarEngineOverrides { - /// When `Some`, `Calendar/Sync` fetches from this backend instead of - /// returning an honest "no CalDAV configuration" error. + /// When `Some`, `Calendar/Sync` fetches from this injected backend instead + /// of building a real per-account CalDAV client. Used only by tests. pub calendar_backend: Option>, } @@ -897,14 +916,93 @@ impl std::fmt::Debug for CalendarEngineOverrides { /// /// `ListEvents`/`GetEvent` always read genuinely persisted data. `Sync` /// fetches from [`CalendarEngineOverrides::calendar_backend`] when injected; -/// otherwise it returns an honest error, since there is no persisted -/// per-account CalDAV configuration to build a real backend from yet (see -/// `nunciod::calendar_sync`'s doc comment). +/// otherwise it runs the real production path -- resolving configured CalDAV +/// accounts and building a real [`nuncio_cal::CalDavClient`] per account from +/// its persisted collection URL and keyring credential. It returns an honest +/// error only when no CalDAV account is configured (see +/// `nunciod::calendar_sync`). struct CalendarGrpcService { db: Arc, + secrets: Arc, overrides: CalendarEngineOverrides, } +impl CalendarGrpcService { + /// Production `Calendar/Sync` path (no injected test backend): resolve the + /// configured CalDAV account(s), build a real [`nuncio_cal::CalDavClient`] + /// per account from its persisted collection URL and keyring credential, + /// and sync the requested calendar/window into the store. Returns the + /// total number of events synced across the resolved accounts. + /// + /// A request naming a specific `account_id` is scoped to that account; an + /// empty `account_id` syncs every configured CalDAV account. When no + /// CalDAV account matches, this returns an honest error rather than a + /// fabricated zero-count success -- there is genuinely no remote calendar + /// to fetch from. + async fn sync_configured_caldav_accounts( + &self, + req: &CalendarSyncRequest, + ) -> Result { + let caldav_accounts: Vec = self + .db + .list_accounts() + .await + .map_err(|e| Status::internal(format!("failed to list accounts: {e}")))? + .into_iter() + .filter(|a| a.protocol == nuncio_core::AccountProtocol::CalDav) + .filter(|a| req.account_id.is_empty() || a.id == req.account_id) + .collect(); + + if caldav_accounts.is_empty() { + return Err(Status::failed_precondition(if req.account_id.is_empty() { + "no CalDAV account is configured; add one with protocol CALDAV and a \ + collection_url before syncing" + .to_string() + } else { + format!( + "no CalDAV account with id '{}' is configured", + req.account_id + ) + })); + } + + let mut total = 0usize; + for account in caldav_accounts { + // The credential lives ONLY in the OS keyring vault, keyed by the + // account's `keyring_secret_key`; it is read just-in-time here and + // wrapped so its backing memory is zeroized on drop. + let password = zeroize::Zeroizing::new( + self.secrets + .get_secret(&account.keyring_secret_key) + .map_err(|e| { + Status::internal(format!( + "failed to read CalDAV credential for account '{}': {e}", + account.id + )) + })?, + ); + + total += crate::calendar_sync::sync_caldav_account( + &self.db, + &account, + &password, + &req.calendar_id, + req.start_window, + req.end_window, + ) + .await + .map_err(|e| { + Status::internal(format!( + "CalDAV sync failed for account '{}': {e}", + account.id + )) + })?; + } + + Ok(total) + } +} + #[tonic::async_trait] impl Calendar for CalendarGrpcService { async fn sync( @@ -923,13 +1021,7 @@ impl Calendar for CalendarGrpcService { ) .await .map_err(|e| Status::internal(format!("calendar sync failed: {e}")))?, - None => { - return Err(Status::internal(format!( - "no CalDAV configuration exists for account '{}'; per-account CalDAV \ - configuration is not yet implemented", - req.account_id - ))); - } + None => self.sync_configured_caldav_accounts(&req).await?, }; Ok(Response::new(CalendarSyncResponse { @@ -2275,6 +2367,7 @@ pub async fn serve_on_listener_with_overrides( // this function's doc comment. let calendar_service = CalendarGrpcService { db: db.clone(), + secrets: secrets.clone(), overrides: calendar_overrides, }; let calendar_interceptor = BearerAuthInterceptor::new(token.clone()); @@ -2875,6 +2968,7 @@ mod tests { smtp_tls_mode: TlsModeProto::ImplicitTls.into(), keyring_secret_key: keyring_secret_key.to_string(), sync_interval_secs: 60, + collection_url: String::new(), smtp_host: "smtp.nuncio.mx".to_string(), smtp_port: 465, } @@ -3263,6 +3357,7 @@ mod tests { smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: key.to_string(), sync_interval_secs: 60, + collection_url: String::new(), }; // Force `save_account` to fail for real by closing the pool, exactly @@ -3306,6 +3401,7 @@ mod tests { smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: key.to_string(), sync_interval_secs: 60, + collection_url: String::new(), }; db.close().await; @@ -3752,6 +3848,7 @@ mod tests { smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: "nuncio/acct-send-1".to_string(), sync_interval_secs: 60, + collection_url: String::new(), }; db.save_account(&config).await.expect("save account"); secrets @@ -3920,6 +4017,7 @@ mod tests { smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: "nuncio/acct-injected-send-1".to_string(), sync_interval_secs: 60, + collection_url: String::new(), }; // Deliberately never stores a keyring credential -- this path must // never need one. @@ -4036,6 +4134,7 @@ mod tests { smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: "nuncio/acct-injected-send-fail-1".to_string(), sync_interval_secs: 60, + collection_url: String::new(), }; db.save_account(&config).await.expect("save account"); @@ -5417,12 +5516,12 @@ mod tests { assert_eq!(err.code(), Code::NotFound); } - /// With no `CalendarEngineOverrides::calendar_backend` injected, `Sync` - /// returns an honest error -- there is no persisted per-account CalDAV - /// configuration to build a real backend from -- never a fabricated - /// `synced_count`. + /// With no `CalendarEngineOverrides::calendar_backend` injected AND no + /// CalDAV account configured, `Sync` returns an honest + /// `FailedPrecondition` error naming the missing account -- never a + /// fabricated `synced_count`. #[tokio::test] - async fn calendar_sync_without_override_reports_honest_error() { + async fn calendar_sync_without_override_or_config_reports_honest_error() { use nuncio_proto::v1::calendar_client::CalendarClient; use nuncio_proto::v1::CalendarSyncRequest; @@ -5441,9 +5540,8 @@ mod tests { })) .await .expect_err("sync with no injected backend and no CalDAV config must fail"); - assert_eq!(err.code(), Code::Internal); + assert_eq!(err.code(), Code::FailedPrecondition); assert!(err.message().contains("acct-cal-no-config")); - assert!(err.message().contains("no CalDAV configuration")); } /// With a [`CalendarEngineOverrides::calendar_backend`] injected, `Sync` diff --git a/crates/nunciod/src/orchestrator.rs b/crates/nunciod/src/orchestrator.rs index 72d1826..2fa48f3 100644 --- a/crates/nunciod/src/orchestrator.rs +++ b/crates/nunciod/src/orchestrator.rs @@ -162,6 +162,7 @@ mod tests { smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: "nuncio/acct-orch-1".to_string(), sync_interval_secs: 60, + collection_url: String::new(), }; engine.save_account(&acct).await.unwrap(); engine.close().await; diff --git a/crates/nunciod/src/outbox.rs b/crates/nunciod/src/outbox.rs index e010fd3..f022ea5 100644 --- a/crates/nunciod/src/outbox.rs +++ b/crates/nunciod/src/outbox.rs @@ -59,6 +59,10 @@ pub enum OutboxExecuteError { /// A database error occurred while resolving the account/message. #[error("database error: {0}")] Store(#[from] DatabaseError), + /// The resolved account is not a mail account (e.g. a CalDAV account), so + /// it has no mail backend to execute an outbound mutation against. + #[error("account '{0}' is not a mail account")] + NotAMailAccount(String), } /// Injectable seam for the resources a mutation needs: the per-account mail @@ -423,7 +427,8 @@ impl RemoteExecutionEnv for ProductionExecutionEnv { ) -> Result, OutboxExecuteError> { let config = self.account(account_id).await?; let password = self.secrets.get_secret(&config.keyring_secret_key)?; - Ok(crate::sync::build_mail_backend(&config, &password)) + crate::sync::build_mail_backend(&config, &password) + .map_err(|_| OutboxExecuteError::NotAMailAccount(account_id.to_string())) } async fn message_sender( diff --git a/crates/nunciod/src/send.rs b/crates/nunciod/src/send.rs index 995b68a..3593ab5 100644 --- a/crates/nunciod/src/send.rs +++ b/crates/nunciod/src/send.rs @@ -243,6 +243,7 @@ mod tests { smtp_tls_mode: TlsMode::ImplicitTls, keyring_secret_key: format!("nuncio/{id}"), sync_interval_secs: 60, + collection_url: String::new(), } } diff --git a/crates/nunciod/src/sync.rs b/crates/nunciod/src/sync.rs index 02265a1..bbb395c 100644 --- a/crates/nunciod/src/sync.rs +++ b/crates/nunciod/src/sync.rs @@ -40,6 +40,10 @@ pub enum SyncError { /// A database persistence error occurred while saving synced messages. #[error("database error: {0}")] Store(#[from] DatabaseError), + /// The account's protocol is not a mail protocol (e.g. a CalDAV account), + /// so it has no inbound mail backend to sync. + #[error("account '{0}' is not a mail account and cannot be mail-synced")] + NotAMailAccount(String), } /// Map a [`RuleAction`] to the short mutation tag persisted on @@ -339,22 +343,28 @@ async fn find_account( /// keyring by the caller). This is the ONLY place a concrete protocol engine /// is chosen; every other function in this module only ever sees `&dyn /// MailBackend`. -pub(crate) fn build_mail_backend(config: &AccountConfig, password: &str) -> Box { +pub(crate) fn build_mail_backend( + config: &AccountConfig, + password: &str, +) -> Result, SyncError> { match config.protocol { - AccountProtocol::ImapSmtp => Box::new(ImapEngine::with_credentials( + AccountProtocol::ImapSmtp => Ok(Box::new(ImapEngine::with_credentials( &config.id, &config.server_host, config.server_port, config.imap_tls_mode, &config.email_address, password, - )), - AccountProtocol::Jmap => Box::new(JmapEngine::with_credentials( + ))), + AccountProtocol::Jmap => Ok(Box::new(JmapEngine::with_credentials( &config.id, &config.server_host, &config.email_address, password, - )), + ))), + // A DAV-protocol account (e.g. CalDAV) has no inbound mail backend; + // it is synced through its own domain service, not the mail path. + AccountProtocol::CalDav => Err(SyncError::NotAMailAccount(config.id.clone())), } } @@ -376,7 +386,7 @@ pub async fn run_account_sync( let setup = async { let config = find_account(db, account_id).await?; let password = secrets.get_secret(&config.keyring_secret_key)?; - Ok::<_, SyncError>(build_mail_backend(&config, &password)) + build_mail_backend(&config, &password) } .await; @@ -419,9 +429,26 @@ pub async fn run_all_accounts_sync( match db.list_accounts().await { Ok(accounts) => { for config in accounts { + // DAV-protocol accounts (e.g. CalDAV) are not mail accounts; + // they sync through their own domain service, so skip them + // here rather than attempting to build a mail backend. + if config.protocol.is_dav() { + continue; + } match secrets.get_secret(&config.keyring_secret_key) { Ok(password) => { - let backend = build_mail_backend(&config, &password); + let backend = match build_mail_backend(&config, &password) { + Ok(backend) => backend, + Err(e) => { + event_bus.process_command(CoreCommand::ReportError { + message: format!( + "sync skipped for account '{}': {e}", + config.id + ), + }); + continue; + } + }; match fetch_and_persist( db, event_bus, @@ -500,6 +527,7 @@ mod tests { smtp_tls_mode: TlsMode::ImplicitTls, keyring_secret_key: format!("nuncio/{id}"), sync_interval_secs: 60, + collection_url: String::new(), } } @@ -591,6 +619,7 @@ mod tests { smtp_tls_mode: TlsMode::ImplicitTls, keyring_secret_key: format!("nuncio/{id}"), sync_interval_secs: 60, + collection_url: String::new(), } } diff --git a/crates/nunciod/tests/accounts_lifecycle_e2e_test.rs b/crates/nunciod/tests/accounts_lifecycle_e2e_test.rs index 278e6a8..a6dd608 100644 --- a/crates/nunciod/tests/accounts_lifecycle_e2e_test.rs +++ b/crates/nunciod/tests/accounts_lifecycle_e2e_test.rs @@ -89,6 +89,7 @@ fn sample_account_config() -> AccountConfig { sync_interval_secs: 60, smtp_host: "smtp.nuncio.mx".to_string(), smtp_port: 25, + collection_url: String::new(), } } diff --git a/crates/nunciod/tests/caldav_production_e2e_test.rs b/crates/nunciod/tests/caldav_production_e2e_test.rs new file mode 100644 index 0000000..dd0b89e --- /dev/null +++ b/crates/nunciod/tests/caldav_production_e2e_test.rs @@ -0,0 +1,241 @@ +//! Full-daemon offline end-to-end test for the PRODUCTION CalDAV sync path. +//! +//! Unlike `calendar_e2e_test.rs`, which injects a `MockCalendarBackend` through +//! the [`nunciod::grpc::CalendarEngineOverrides`] seam, this test drives the +//! real production path with NO injected backend: it configures a first-class +//! CalDAV account over the authenticated `Accounts/AddAccount` RPC (protocol +//! CALDAV + a `collection_url` pointing at a WireMock server + a keyring +//! credential), then calls `Calendar/Sync`. The daemon resolves the configured +//! CalDAV account, reads its credential from the (mock) vault, builds a REAL +//! [`nuncio_cal::CalDavClient`] from the persisted collection URL, issues a +//! genuine CalDAV `REPORT` against WireMock, and persists the fetched events. +//! +//! This proves the production wiring end to end while remaining fully offline: +//! WireMock stands in for the remote CalDAV server, and a +//! [`SecretManager::mock`] vault stands in for the OS keyring -- no real +//! network and no real keyring are ever touched. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use nuncio_core::EventBus; +use nuncio_filter::FilterEngine; +use nuncio_proto::v1::{ + AccountConfig as AccountConfigProto, AccountProtocol as AccountProtocolProto, + AddAccountRequest, CalendarSyncRequest, ListEventsRequest, TlsMode as TlsModeProto, +}; +use nuncio_store::db::DatabaseEngine; +use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT}; +use nunciod::grpc::{ + serve_on_listener_with_overrides, AccountsEngineOverrides, CalendarEngineOverrides, + ContactsEngineOverrides, MailEngineOverrides, +}; +use std::sync::Arc; +use tokio::net::TcpListener; +use tonic::Code; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ACCOUNT_ID: &str = "acct-caldav-e2e-1"; +const ACCOUNT_EMAIL: &str = "james.maes@nuncio.mx"; +const KEYRING_KEY: &str = "nuncio/acct-caldav-e2e-1"; +const CALDAV_PASSWORD: &str = "wiremock-app-token"; +const CALENDAR_ID: &str = "cal-work"; + +/// A canned CalDAV `multistatus` response with two VEVENTs, mirroring the +/// transport-level fixture used by `nuncio-cal`'s own WireMock test. +fn multistatus_body() -> String { + r#" + + + /calendars/work/standup.ics + + + BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +SUMMARY:Production CalDAV Standup +LOCATION:Conference Room B +DTSTART:20240715T130000Z +DTEND:20240715T140000Z +END:VEVENT +END:VCALENDAR + + + + + /calendars/work/review.ics + + + BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +SUMMARY:Production UTC Review +DTSTART:20240301T120000Z +DTEND:20240301T130000Z +END:VEVENT +END:VCALENDAR + + + +"# + .to_string() +} + +/// Boots the real daemon gRPC server with DEFAULT overrides (no injected +/// calendar backend), backed by a real temp file-backed [`DatabaseEngine`] and +/// the given mock vault. Returns the server address and the minted bearer +/// token; the `TempDir` guard is returned so the caller controls its lifetime. +async fn boot_daemon( + secrets: Arc, +) -> (std::net::SocketAddr, String, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("create temp dir for file-backed db"); + let db_path = dir.path().join("caldav_prod_e2e.db"); + let db = Arc::new( + DatabaseEngine::connect_file(&db_path, &secrets) + .await + .expect("open temp file-backed database"), + ); + + let token_bytes = secrets + .get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32) + .expect("mint gRPC bearer token from mock vault"); + let token = hex::encode(token_bytes); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral loopback port"); + let addr = listener.local_addr().expect("listener has local addr"); + + let event_bus = Arc::new(EventBus::new()); + let server_secrets = secrets.clone(); + let server_token = token.clone(); + let server_filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set")); + tokio::spawn(async move { + let _ = serve_on_listener_with_overrides( + listener, + event_bus, + db, + server_filter_engine, + server_secrets, + server_token, + MailEngineOverrides::default(), + // No injected calendar backend: exercise the real production path. + CalendarEngineOverrides::default(), + ContactsEngineOverrides::default(), + AccountsEngineOverrides::default(), + ) + .await; + }); + + (addr, token, dir) +} + +/// The full offline proof of the production CalDAV sync path: configure a +/// CalDAV account over the real `Accounts` API, then `Calendar/Sync` builds a +/// real `CalDavClient` and syncs events fetched from WireMock into the store, +/// immediately visible to `ListEvents` -- all over the authenticated gRPC API. +#[tokio::test] +async fn caldav_production_sync_builds_real_client_and_persists_events() { + let mock_server = MockServer::start().await; + Mock::given(method("REPORT")) + .and(path("/calendars/work/")) + .respond_with(ResponseTemplate::new(207).set_body_string(multistatus_body())) + .mount(&mock_server) + .await; + let collection_url = format!("{}/calendars/work/", mock_server.uri()); + + let secrets = Arc::new(SecretManager::mock()); + let (addr, token, _db_dir) = boot_daemon(secrets).await; + let addr_str = addr.to_string(); + + // ---- Configure a first-class CalDAV account over the Accounts API ---- + let mut accounts_client = nuncio_proto::client::connect_accounts(&addr_str, &token) + .await + .expect("accounts client connects"); + accounts_client + .add_account(AddAccountRequest { + config: Some(AccountConfigProto { + id: ACCOUNT_ID.to_string(), + name: "Work Calendar".to_string(), + email_address: ACCOUNT_EMAIL.to_string(), + protocol: AccountProtocolProto::Caldav.into(), + server_host: String::new(), + server_port: 0, + use_tls: true, + imap_tls_mode: TlsModeProto::ImplicitTls.into(), + smtp_tls_mode: TlsModeProto::ImplicitTls.into(), + keyring_secret_key: KEYRING_KEY.to_string(), + sync_interval_secs: 300, + smtp_host: String::new(), + smtp_port: 0, + collection_url: collection_url.clone(), + }), + password: CALDAV_PASSWORD.to_string(), + }) + .await + .expect("CalDAV account is accepted and persisted"); + + // ---- Calendar/Sync runs the PRODUCTION path (no injected backend) ---- + let mut calendar_client = nuncio_proto::client::connect_calendar(&addr_str, &token) + .await + .expect("calendar client connects"); + let sync_response = calendar_client + .sync(CalendarSyncRequest { + account_id: ACCOUNT_ID.to_string(), + calendar_id: CALENDAR_ID.to_string(), + start_window: 1_700_000_000, + end_window: 1_800_000_000, + }) + .await + .expect("production CalDAV sync succeeds") + .into_inner(); + assert_eq!(sync_response.synced_count, 2); + + // Immediately visible: `Sync` awaits full fetch-and-persist completion. + let list_response = calendar_client + .list_events(ListEventsRequest { + account_id: ACCOUNT_ID.to_string(), + calendar_id: CALENDAR_ID.to_string(), + start_window: 1_700_000_000, + end_window: 1_800_000_000, + }) + .await + .expect("list_events succeeds") + .into_inner(); + assert_eq!(list_response.events.len(), 2); + let summaries: Vec<&str> = list_response + .events + .iter() + .map(|e| e.summary.as_str()) + .collect(); + assert!(summaries.contains(&"Production CalDAV Standup")); + assert!(summaries.contains(&"Production UTC Review")); + + // ---- With no CalDAV account matching, the honest error still fires ---- + let missing = calendar_client + .sync(CalendarSyncRequest { + account_id: "acct-does-not-exist".to_string(), + calendar_id: CALENDAR_ID.to_string(), + start_window: 1_700_000_000, + end_window: 1_800_000_000, + }) + .await + .expect_err("a sync targeting an unconfigured account must fail honestly"); + assert_eq!(missing.code(), Code::FailedPrecondition); + + // ---- An unauthenticated call to Calendar is rejected ---- + let mut unauthenticated_client = + nuncio_proto::v1::calendar_client::CalendarClient::connect(format!("http://{addr_str}")) + .await + .expect("client connects at the transport level without any auth"); + let err = unauthenticated_client + .list_events(ListEventsRequest { + account_id: ACCOUNT_ID.to_string(), + calendar_id: CALENDAR_ID.to_string(), + start_window: 0, + end_window: i64::MAX, + }) + .await + .expect_err("missing bearer token must be rejected"); + assert_eq!(err.code(), Code::Unauthenticated); +} diff --git a/crates/nunciod/tests/outbox_executor_e2e_test.rs b/crates/nunciod/tests/outbox_executor_e2e_test.rs index 855a4c9..6f823ff 100644 --- a/crates/nunciod/tests/outbox_executor_e2e_test.rs +++ b/crates/nunciod/tests/outbox_executor_e2e_test.rs @@ -97,6 +97,7 @@ fn sample_account() -> nuncio_core::AccountConfig { smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: format!("nuncio/{ACCOUNT_ID}"), sync_interval_secs: 60, + collection_url: String::new(), } } diff --git a/crates/nunciod/tests/spine_e2e_test.rs b/crates/nunciod/tests/spine_e2e_test.rs index 4a2c844..058225e 100644 --- a/crates/nunciod/tests/spine_e2e_test.rs +++ b/crates/nunciod/tests/spine_e2e_test.rs @@ -68,6 +68,7 @@ fn sample_account_config() -> AccountConfig { sync_interval_secs: 60, smtp_host: "smtp.nuncio.mx".to_string(), smtp_port: 465, + collection_url: String::new(), } }