feat: production-wire Calendar sync via first-class CalDAV accounts #272

Merged
KofTwentyTwo merged 7 commits from story/m6.5-caldav-account into dev 2026-07-31 17:41:25 +00:00
18 changed files with 816 additions and 78 deletions

No files matched your search

+11 -1
View File
@@ -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<String>,
/// 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(),
+144 -17
View File
@@ -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<nuncio_proto::v1::AccountProtocol, String> {
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(),
@@ -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(),
+112 -13
View File
@@ -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]
Binary file not shown.
@@ -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
+84 -4
View File
@@ -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<String> = sqlx::query("PRAGMA table_info(accounts)")
.fetch_all(&self.pool)
.await
.map_err(DatabaseError::Query)?
.iter()
.map(|row| row.get::<String, _>("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<i64>,
String,
String,
Option<String>,
)> = 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)
+7 -1
View File
@@ -25,6 +25,7 @@ type SalvagedAccountRow = (
Option<i64>,
String,
String,
Option<String>,
);
/// Summary report of database self-healing recovery output.
@@ -383,7 +384,7 @@ impl SqliteRecoveryEngine {
async fn salvage_accounts(pool: &sqlx::SqlitePool) -> Vec<nuncio_core::AccountConfig> {
let rows: Result<Vec<SalvagedAccountRow>, _> = 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();
}
+36 -10
View File
@@ -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<usize, CalendarSyncError> {
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::*;
+122 -24
View File
@@ -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<Arc<dyn CalendarBackend>>,
}
@@ -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<DatabaseEngine>,
secrets: Arc<SecretManager>,
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<usize, Status> {
let caldav_accounts: Vec<nuncio_core::AccountConfig> = 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`
+1
View File
@@ -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;
+6 -1
View File
@@ -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<Box<dyn MailBackend>, 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(
+1
View File
@@ -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(),
}
}
+36 -7
View File
@@ -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<dyn MailBackend> {
pub(crate) fn build_mail_backend(
config: &AccountConfig,
password: &str,
) -> Result<Box<dyn MailBackend>, 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(),
}
}
@@ -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(),
}
}
@@ -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#"<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response>
<d:href>/calendars/work/standup.ics</d:href>
<d:propstat>
<d:prop>
<c:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
SUMMARY:Production CalDAV Standup
LOCATION:Conference Room B
DTSTART:20240715T130000Z
DTEND:20240715T140000Z
END:VEVENT
END:VCALENDAR</c:calendar-data>
</d:prop>
</d:propstat>
</d:response>
<d:response>
<d:href>/calendars/work/review.ics</d:href>
<d:propstat>
<d:prop>
<c:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
SUMMARY:Production UTC Review
DTSTART:20240301T120000Z
DTEND:20240301T130000Z
END:VEVENT
END:VCALENDAR</c:calendar-data>
</d:prop>
</d:propstat>
</d:response>
</d:multistatus>"#
.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<SecretManager>,
) -> (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);
}
@@ -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(),
}
}
+1
View File
@@ -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(),
}
}