feat(contacts): drive contacts sync from stored CardDAV accounts #431

Merged
KofTwentyTwo merged 1 commits from story/wire-contacts-sync into dev 2026-08-15 02:48:47 +00:00
11 changed files with 572 additions and 72 deletions

No files matched your search

+7 -5
View File
@@ -211,13 +211,15 @@ pub enum AccountSubcommand {
/// 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.
/// Account protocol: imap-smtp (default), jmap, caldav, or carddav. A
/// caldav or carddav 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)")]
/// Fully-qualified DAV collection URL -- a CalDAV calendar collection
/// or a CardDAV address book (required for a caldav or carddav
/// account; ignored for mail accounts).
#[arg(long, help = "DAV collection URL (caldav/carddav only)")]
collection_url: Option<String>,
/// IMAP server hostname.
#[arg(long, default_value = "mail.kof22.com", help = "IMAP server hostname")]
+36 -7
View File
@@ -75,8 +75,9 @@ fn parse_account_protocol(protocol: &str) -> Result<nuncio_proto::v1::AccountPro
"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),
"carddav" | "card-dav" | "card_dav" => Ok(nuncio_proto::v1::AccountProtocol::Carddav),
other => Err(format!(
"invalid protocol '{other}' (expected imap-smtp, jmap, or caldav)"
"invalid protocol '{other}' (expected imap-smtp, jmap, caldav, or carddav)"
)),
}
}
@@ -104,6 +105,10 @@ fn account_transport_json(config: &nuncio_proto::v1::AccountConfig) -> serde_jso
"protocol": "caldav",
"collection_url": t.collection_url,
}),
Some(Transport::Carddav(t)) => json!({
"protocol": "carddav",
"collection_url": t.collection_url,
}),
None => json!({ "protocol": "unspecified" }),
}
}
@@ -135,6 +140,7 @@ fn account_transport_summary(config: &nuncio_proto::v1::AccountConfig) -> String
),
Some(Transport::Jmap(t)) => format!("JMAP {}", t.endpoint_host),
Some(Transport::Dav(t)) => format!("CalDAV {}", t.collection_url),
Some(Transport::Carddav(t)) => format!("CardDAV {}", t.collection_url),
None => "(no transport configured)".to_string(),
}
}
@@ -2117,9 +2123,20 @@ impl HeadlessRunner {
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);
// Both DAV flavours are addressed by a collection URL rather than a
// mail host/port, so neither can be added without one.
let dav_kind = match account_protocol {
nuncio_proto::v1::AccountProtocol::Caldav => Some("caldav"),
nuncio_proto::v1::AccountProtocol::Carddav => Some("carddav"),
_ => None,
};
if let Some(kind) = dav_kind {
if collection_url.map(str::trim).unwrap_or_default().is_empty() {
return Self::render_error(
&format!("a {kind} account requires --collection-url"),
json_mode,
);
}
}
let keyring_key = format!("nuncio/{}", email);
@@ -2152,6 +2169,13 @@ impl HeadlessRunner {
collection_url: collection_url.unwrap_or_default().to_string(),
})
}
nuncio_proto::v1::AccountProtocol::Carddav => {
nuncio_proto::v1::account_config::Transport::Carddav(
nuncio_proto::v1::DavTransport {
collection_url: collection_url.unwrap_or_default().to_string(),
},
)
}
nuncio_proto::v1::AccountProtocol::Unspecified => {
return Self::render_error("account protocol is required", json_mode)
}
@@ -2191,20 +2215,25 @@ impl HeadlessRunner {
.map(|c| c.id)
.unwrap_or(account_id);
let is_jmap = account_protocol == nuncio_proto::v1::AccountProtocol::Jmap;
if is_caldav {
if let Some(kind) = dav_kind {
let collection = collection_url.unwrap_or_default();
let label = if kind == "carddav" {
"CardDAV"
} else {
"CalDAV"
};
if json_mode {
format_json(&json!({
"configured": true,
"account_id": account_id,
"email": email,
"protocol": "caldav",
"protocol": kind,
"collection_url": collection,
"keyring_key": keyring_key
}))
} else {
format!(
"CalDAV account '{email}' (ID: {account_id}) added via nunciod daemon with collection URL {collection}"
"{label} account '{email}' (ID: {account_id}) added via nunciod daemon with collection URL {collection}"
)
}
} else if is_jmap {
+52 -11
View File
@@ -16,6 +16,10 @@ pub enum AccountProtocol {
/// `collection_url` and its own keyring credential; it does not use the
/// IMAP/JMAP/SMTP mail endpoints.
CalDav,
/// CardDAV (RFC 6352) address-book-collection protocol engine. Like
/// CalDAV, a CardDAV account is a standalone account entry addressed by
/// its `collection_url` and its own keyring credential.
CardDav,
}
impl AccountProtocol {
@@ -24,8 +28,22 @@ impl AccountProtocol {
/// require [`AccountConfig::collection_url`] and do not require the
/// IMAP/SMTP mail endpoint fields.
pub fn is_dav(self) -> bool {
matches!(self, AccountProtocol::CalDav | AccountProtocol::CardDav)
}
/// Whether this protocol is specifically CalDAV. Callers that drive
/// calendar sync must use this rather than [`Self::is_dav`], which is also
/// true for CardDAV address books.
pub fn is_caldav(self) -> bool {
matches!(self, AccountProtocol::CalDav)
}
/// Whether this protocol is specifically CardDAV. Callers that drive
/// contacts sync must use this rather than [`Self::is_dav`], which is also
/// true for CalDAV calendars.
pub fn is_carddav(self) -> bool {
matches!(self, AccountProtocol::CardDav)
}
}
/// Security and transport encryption mode for mail protocol streams (IMAP & SMTP).
@@ -104,9 +122,11 @@ pub struct JmapTransport {
pub endpoint_host: String,
}
/// DAV transport parameters (CalDAV RFC 4791 today, CardDAV later). A DAV
/// account is addressed by a fully-qualified collection URL rather than a mail
/// host/port, because a DAV client dispatches requests directly at that URL.
/// DAV transport parameters (CalDAV RFC 4791 calendars and CardDAV RFC 6352
/// address books). A DAV account is addressed by a fully-qualified collection
/// URL rather than a mail host/port, because a DAV client dispatches requests
/// directly at that URL. Which flavour of DAV is spoken is carried by the
/// enclosing [`Transport`] variant, not by this struct.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DavTransport {
/// Fully-qualified collection URL (scheme + host + path).
@@ -124,8 +144,10 @@ pub enum Transport {
ImapSmtp(ImapSmtpTransport),
/// JMAP mail.
Jmap(JmapTransport),
/// WebDAV collection (CalDAV today, CardDAV later).
/// CalDAV calendar collection.
Dav(DavTransport),
/// CardDAV address-book collection.
CardDav(DavTransport),
}
impl Transport {
@@ -135,6 +157,7 @@ impl Transport {
Transport::ImapSmtp(_) => AccountProtocol::ImapSmtp,
Transport::Jmap(_) => AccountProtocol::Jmap,
Transport::Dav(_) => AccountProtocol::CalDav,
Transport::CardDav(_) => AccountProtocol::CardDav,
}
}
}
@@ -211,11 +234,23 @@ impl AccountConfig {
self.transport.protocol()
}
/// Whether this account speaks WebDAV against a collection URL.
/// Whether this account speaks WebDAV against a collection URL (CalDAV or
/// CardDAV). Callers driving a specific protocol's sync must use
/// [`Self::is_caldav`] or [`Self::is_carddav`] instead.
pub fn is_dav(&self) -> bool {
matches!(self.transport, Transport::Dav(_) | Transport::CardDav(_))
}
/// Whether this account is specifically a CalDAV calendar account.
pub fn is_caldav(&self) -> bool {
matches!(self.transport, Transport::Dav(_))
}
/// Whether this account is specifically a CardDAV address-book account.
pub fn is_carddav(&self) -> bool {
matches!(self.transport, Transport::CardDav(_))
}
/// The account's IMAP/SMTP transport parameters, present only for an
/// IMAP/SMTP account (the only transport with an SMTP outbound endpoint).
pub fn imap_smtp(&self) -> Option<&ImapSmtpTransport> {
@@ -225,10 +260,10 @@ impl AccountConfig {
}
}
/// The account's DAV collection URL, present only for a DAV account.
/// The account's DAV collection URL, present for either DAV flavour.
pub fn dav_collection_url(&self) -> Option<&str> {
match &self.transport {
Transport::Dav(t) => Some(t.collection_url.as_str()),
Transport::Dav(t) | Transport::CardDav(t) => Some(t.collection_url.as_str()),
_ => None,
}
}
@@ -294,9 +329,15 @@ impl AccountConfig {
});
}
}
Transport::Dav(t) => {
// A DAV account is addressed by its collection URL, not by mail
// host/port endpoints.
Transport::Dav(t) | Transport::CardDav(t) => {
// Both DAV flavours are addressed by their collection URL, not
// by mail host/port endpoints, so they validate identically;
// only the endpoint label in the error differs.
let endpoint = if self.is_carddav() {
"carddav"
} else {
"caldav"
};
let url = t.collection_url.trim();
if url.is_empty() {
return Err(ConfigError::EmptyField {
@@ -313,7 +354,7 @@ impl AccountConfig {
let host = url_host(url).unwrap_or_default();
if !is_loopback_host(host) {
return Err(ConfigError::CleartextToRemoteHost {
endpoint: "caldav",
endpoint,
host: host.to_string(),
});
}
Binary file not shown.
@@ -299,6 +299,10 @@ enum AccountProtocol {
// 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;
// CardDAV (RFC 6352) address-book-collection account. Like CalDAV, it is its
// own first-class account entry carrying `collection_url` and its own
// keyring credential.
ACCOUNT_PROTOCOL_CARDDAV = 4;
}
// Mirrors `nuncio_core::TlsMode`.
@@ -337,12 +341,14 @@ message JmapTransport {
string endpoint_host = 1;
}
// DAV transport parameters (CalDAV RFC 4791 today, CardDAV later). A DAV
// account is addressed by a fully-qualified collection URL rather than a mail
// host/port, because a DAV client dispatches requests directly at that URL.
// DAV transport parameters (CalDAV RFC 4791 calendars and CardDAV RFC 6352
// address books). A DAV account is addressed by a fully-qualified collection
// URL rather than a mail host/port, because a DAV client dispatches requests
// directly at that URL. Which flavour of DAV is spoken is carried by the
// `transport` oneof case, not by this message.
message DavTransport {
// Fully-qualified collection URL (scheme + host + path), e.g. a CalDAV
// calendar collection or, later, a CardDAV address-book collection.
// calendar collection or a CardDAV address-book collection.
string collection_url = 1;
}
@@ -385,6 +391,7 @@ message AccountConfig {
ImapSmtpTransport imap_smtp = 15;
JmapTransport jmap = 16;
DavTransport dav = 17;
DavTransport carddav = 19;
}
// Whether the filter engine runs against this account's incoming mail.
//
@@ -1322,10 +1329,11 @@ message ExportResponse {
// `get_calendar_event`), plus a real `Sync` that fetches events from a
// `CalendarBackend` and persists them.
//
// This is an honest surface: there is currently no way to persist a
// per-account CalDAV collection URL/credential, so `Sync` in production (no
// injected test backend) has no real CalDAV connection to make and returns
// an error rather than a fabricated success. There are deliberately no
// This is an honest surface: in production `Sync` drives real CalDAV accounts
// resolved from the store (`ACCOUNT_PROTOCOL_CALDAV` + `dav.collection_url`,
// credential read from the OS keyring). With no such account configured it
// returns `FAILED_PRECONDITION` rather than a fabricated success. There are
// deliberately no
// create/delete RPCs here -- this service does not add real CalDAV write
// support.
//
@@ -1428,10 +1436,10 @@ message GetEventResponse {
// them, and a `CreateContact` that persists a locally-authored contact
// directly to that same store.
//
// This is an honest surface: there is currently no way to persist a
// per-account CardDAV address book collection URL/credential, so `Sync` in
// production (no injected test backend) has no real CardDAV connection to
// make and returns an error rather than a fabricated success.
// This is an honest surface: in production `Sync` drives real CardDAV accounts
// resolved from the store (`ACCOUNT_PROTOCOL_CARDDAV` + `carddav.collection_url`,
// credential read from the OS keyring). With no such account configured it
// returns `FAILED_PRECONDITION` rather than a fabricated success.
// `CreateContact` is NOT a CardDAV write-back -- it only ever writes to the
// daemon's own local store, the same way the address book has always worked
// here, just now actually persisted through the daemon's real store instead
@@ -1445,10 +1453,11 @@ message GetEventResponse {
// See `nunciod::grpc::serve_on_listener`.
service Contacts {
// Sync fetches contacts for `account_id` from the account's contacts
// backend and persists them via the store. With no per-account CardDAV
// configuration available in production, this returns `Status::internal`
// describing that gap unless a test double has been injected -- never a
// fabricated `synced_count`.
// backend and persists them via the store. In production it builds a real
// CardDAV client per configured CardDAV account; if `account_id` names an
// account that is not CardDAV, or no CardDAV account is configured at all,
// it returns `FAILED_PRECONDITION` -- never a fabricated `synced_count`.
// An empty `account_id` syncs every configured CardDAV account.
rpc Sync(ContactsSyncRequest) returns (ContactsSyncResponse);
// ListContacts returns a keyset-paginated page of persisted contacts for
// `account_id`, ordered by interaction count (descending) then display
+43 -1
View File
@@ -1404,7 +1404,7 @@ impl DatabaseEngine {
TlsMode::ImplicitTls,
String::new(),
),
Transport::Dav(t) => (
Transport::Dav(t) | Transport::CardDav(t) => (
String::new(),
0u16,
TlsMode::ImplicitTls,
@@ -1563,6 +1563,11 @@ impl DatabaseEngine {
collection_url: collection_url.unwrap_or_default(),
})
}
nuncio_core::AccountProtocol::CardDav => {
nuncio_core::Transport::CardDav(nuncio_core::DavTransport {
collection_url: collection_url.unwrap_or_default(),
})
}
};
Ok(nuncio_core::AccountConfig {
id,
@@ -4724,6 +4729,43 @@ mod tests {
);
}
/// A CardDAV account round-trips as CardDAV, not as the CalDAV variant it
/// shares physical columns with: both flavours persist their URL in
/// `collection_url`, so only the `protocol` discriminator distinguishes
/// them on reload, and confusing the two would silently point contacts
/// sync at a calendar collection (or vice versa).
#[tokio::test]
async fn carddav_account_round_trips_as_carddav_not_caldav() {
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
let carddav = nuncio_core::AccountConfig {
id: "acct-carddav-store-1".to_string(),
name: "Work Address Book".to_string(),
email_address: "card@nuncio.mx".to_string(),
keyring_secret_key: "nuncio/acct-carddav-store-1".to_string(),
sync_interval_secs: 300,
filters_enabled: false,
transport: nuncio_core::Transport::CardDav(nuncio_core::DavTransport {
collection_url: "https://dav.example.com/addressbooks/user/default/".to_string(),
}),
};
engine.save_account(&carddav).await.expect("save succeeds");
let fetched = engine
.get_account("acct-carddav-store-1")
.await
.expect("get succeeds")
.expect("account present");
assert_eq!(fetched, carddav);
assert_eq!(fetched.protocol(), nuncio_core::AccountProtocol::CardDav);
assert!(fetched.is_carddav());
assert!(!fetched.is_caldav());
assert_eq!(
fetched.dav_collection_url(),
Some("https://dav.example.com/addressbooks/user/default/")
);
}
/// Regression proof that accounts of DIFFERENT transports coexist on disk
/// and each loads back as its exact original transport variant -- the flat
/// physical columns must not blur one transport into another on reload.
+5
View File
@@ -458,6 +458,11 @@ impl SqliteRecoveryEngine {
collection_url: collection_url.unwrap_or_default(),
})
}
nuncio_core::AccountProtocol::CardDav => {
nuncio_core::Transport::CardDav(nuncio_core::DavTransport {
collection_url: collection_url.unwrap_or_default(),
})
}
};
nuncio_core::AccountConfig {
id,
+34 -12
View File
@@ -5,18 +5,12 @@
//! single account, then persists each contact via
//! [`DatabaseEngine::save_contact`].
//!
//! # No production entry point yet
//!
//! 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
//! CardDAV address book collection URL and credential and builds a real
//! `CardDavClient` from them: `nuncio_core::AccountConfig` carries no CardDAV
//! 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 `MockContactsBackend`, and it is the same seam a future
//! story's production entry point will call once per-account CardDAV
//! configuration exists.
use nuncio_contacts::ContactsBackend;
//! [`sync_carddav_account`] is the production entry point: it resolves a real
//! account's persisted CardDAV address-book collection URL, builds a real
//! [`CardDavClient`] from it plus the credential the caller read from the OS
//! keyring, and funnels through the same [`sync_with_backend`] seam that tests
//! drive with `MockContactsBackend`.
use nuncio_contacts::{CardDavAccountConfig, CardDavClient, ContactsBackend};
use nuncio_store::db::DatabaseError;
use thiserror::Error;
@@ -68,6 +62,34 @@ pub async fn sync_with_backend(
Ok(synced)
}
/// Production entry point: build a real [`CardDavClient`] for `account` from
/// its persisted `collection_url` and the `password` resolved from the OS
/// keyring vault, then fetch-and-persist that account's contacts via
/// [`sync_with_backend`]. Returns the number of contacts synced.
///
/// `account`'s collection URL must be a fully-qualified CardDAV address-book
/// 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
/// [`ContactsSyncError`].
pub async fn sync_carddav_account(
db: &nuncio_store::db::DatabaseEngine,
account: &nuncio_core::AccountConfig,
password: &str,
) -> Result<usize, ContactsSyncError> {
tracing::info!(
account_id = %account.id,
"contacts sync: dispatching CardDAV sync for account"
);
let client = CardDavClient::new(CardDavAccountConfig {
account_id: account.id.clone(),
carddav_url: account.dav_collection_url().unwrap_or_default().to_string(),
username: account.email_address.clone(),
auth_token: nuncio_core::redact::Redacted::new(password.to_string()),
});
sync_with_backend(db, &client, &account.id).await
}
#[cfg(test)]
mod tests {
use super::*;
+96 -19
View File
@@ -398,6 +398,7 @@ fn transport_kind(transport: &nuncio_core::Transport) -> &'static str {
nuncio_core::Transport::ImapSmtp(_) => "imap_smtp",
nuncio_core::Transport::Jmap(_) => "jmap",
nuncio_core::Transport::Dav(_) => "dav",
nuncio_core::Transport::CardDav(_) => "carddav",
}
}
@@ -422,6 +423,9 @@ fn map_account_config_to_proto(config: nuncio_core::AccountConfig) -> AccountCon
nuncio_core::Transport::Dav(t) => TransportProto::Dav(DavTransportProto {
collection_url: t.collection_url,
}),
nuncio_core::Transport::CardDav(t) => TransportProto::Carddav(DavTransportProto {
collection_url: t.collection_url,
}),
};
AccountConfigProto {
id: config.id,
@@ -480,10 +484,15 @@ fn map_account_config_from_proto(
Some(TransportProto::Dav(t)) => nuncio_core::Transport::Dav(nuncio_core::DavTransport {
collection_url: t.collection_url,
}),
Some(TransportProto::Carddav(t)) => {
nuncio_core::Transport::CardDav(nuncio_core::DavTransport {
collection_url: t.collection_url,
})
}
None => {
return Err(errors::status(
ErrorReason::ValidationFailed,
"account transport is required (one of imap_smtp, jmap, dav must be set)",
"account transport is required (one of imap_smtp, jmap, dav, carddav must be set)",
))
}
};
@@ -583,13 +592,13 @@ impl AccountConnectionTester for RealAccountConnectionTester {
),
}
}
nuncio_core::Transport::Dav(_) => {
nuncio_core::Transport::Dav(_) | nuncio_core::Transport::CardDav(_) => {
return AccountConnectionReport {
imap: ProtocolProbe::failed(
"IMAP connection probe is not applicable to a CalDAV account".to_string(),
"IMAP connection probe is not applicable to a DAV account".to_string(),
),
smtp: ProtocolProbe::failed(
"SMTP connection probe is not applicable to a CalDAV account".to_string(),
"SMTP connection probe is not applicable to a DAV account".to_string(),
),
}
}
@@ -1239,7 +1248,7 @@ impl CalendarGrpcService {
.await
.map_err(|e| Status::internal(format!("failed to list accounts: {e}")))?
.into_iter()
.filter(|a| a.is_dav())
.filter(|a| a.is_caldav())
.filter(|a| req.account_id.is_empty() || a.id == req.account_id)
.collect();
@@ -1465,15 +1474,88 @@ impl std::fmt::Debug for ContactsEngineOverrides {
/// `ListContacts`/`GetContact` always read genuinely persisted data.
/// `CreateContact` persists a locally-authored contact directly to the
/// store -- real local persistence, never a fabricated CardDAV write-back.
/// `Sync` fetches from [`ContactsEngineOverrides::contacts_backend`] when
/// injected; otherwise it returns an honest error, since there is no
/// persisted per-account CardDAV configuration to build a real backend from
/// yet (see `nunciod::contacts_sync`'s doc comment).
/// `Sync` fetches from [`ContactsEngineOverrides::contacts_backend`] when a
/// test double is injected; otherwise it takes the production path, building a
/// real `CardDavClient` per configured CardDAV account (see
/// [`Self::sync_configured_carddav_accounts`]).
struct ContactsGrpcService {
db: Arc<DatabaseEngine>,
secrets: Arc<SecretManager>,
overrides: ContactsEngineOverrides,
}
impl ContactsGrpcService {
/// Production path: resolve every configured CardDAV account from the
/// store, build a real `CardDavClient` per account from its persisted
/// collection URL and keyring credential, and sync its contacts into the
/// store. Returns the total number of contacts synced.
///
/// A request naming a specific `account_id` is scoped to that account; an
/// empty `account_id` syncs every configured CardDAV account. When no
/// CardDAV account matches -- including when the named account exists but
/// speaks some other protocol -- this returns an honest error rather than
/// a fabricated zero-count success, because there is genuinely no remote
/// address book to fetch from.
async fn sync_configured_carddav_accounts(
&self,
req: &ContactsSyncRequest,
) -> Result<usize, Status> {
let carddav_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.is_carddav())
.filter(|a| req.account_id.is_empty() || a.id == req.account_id)
.collect();
if carddav_accounts.is_empty() {
return Err(errors::status(
ErrorReason::NotConfigured,
if req.account_id.is_empty() {
"no CardDAV account is configured; add one with protocol CARDDAV and a \
collection_url before syncing"
.to_string()
} else {
format!(
"no CardDAV account with id '{}' is configured",
req.account_id
)
},
));
}
let mut total = 0usize;
for account in carddav_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 CardDAV credential for account '{}': {e}",
account.id
))
})?,
);
total += crate::contacts_sync::sync_carddav_account(&self.db, &account, &password)
.await
.map_err(|e| {
Status::internal(format!(
"CardDAV sync failed for account '{}': {e}",
account.id
))
})?;
}
Ok(total)
}
}
#[tonic::async_trait]
impl Contacts for ContactsGrpcService {
async fn sync(
@@ -1488,13 +1570,7 @@ impl Contacts for ContactsGrpcService {
.await
.map_err(|e| Status::internal(format!("contacts sync failed: {e}")))?
}
None => {
return Err(Status::internal(format!(
"no CardDAV configuration exists for account '{}'; per-account CardDAV \
configuration is not yet implemented",
req.account_id
)));
}
None => self.sync_configured_carddav_accounts(&req).await?,
};
tracing::info!(
@@ -3751,6 +3827,7 @@ pub async fn serve_on_listener_with_overrides_and_shutdown(
// this function's doc comment.
let contacts_service = ContactsGrpcService {
db,
secrets,
overrides: contacts_overrides,
};
let contacts_interceptor = BearerAuthInterceptor::new(token);
@@ -8356,10 +8433,10 @@ mod tests {
account_id: "acct-contacts-no-config".to_string(),
}))
.await
.expect_err("sync with no injected backend and no CardDAV config must fail");
assert_eq!(err.code(), Code::Internal);
.expect_err("sync with no injected backend and no CardDAV account must fail");
assert_eq!(err.code(), Code::FailedPrecondition);
assert!(err.message().contains("acct-contacts-no-config"));
assert!(err.message().contains("no CardDAV configuration"));
assert!(err.message().contains("no CardDAV account"));
}
/// With a [`ContactsEngineOverrides::contacts_backend`] injected, `Sync`
+3 -1
View File
@@ -482,7 +482,9 @@ pub(crate) fn build_mail_backend(
))),
// A DAV-protocol account (e.g. CalDAV) has no inbound mail backend;
// it is synced through its own domain service, not the mail path.
Transport::Dav(_) => Err(SyncError::NotAMailAccount(config.id.clone())),
Transport::Dav(_) | Transport::CardDav(_) => {
Err(SyncError::NotAMailAccount(config.id.clone()))
}
}
}
@@ -0,0 +1,271 @@
//! Full-daemon offline end-to-end test for the PRODUCTION CardDAV sync path.
//!
//! Unlike `contacts_e2e_test.rs`, which injects a `MockContactsBackend` through
//! the [`nunciod::grpc::ContactsEngineOverrides`] seam, this test drives the
//! real production path with NO injected backend: it configures a first-class
//! CardDAV account over the authenticated `Accounts/AddAccount` RPC (protocol
//! CARDDAV + a `collection_url` pointing at a WireMock server + a keyring
//! credential), then calls `Contacts/Sync`. The daemon resolves the configured
//! CardDAV account, reads its credential from the (mock) vault, builds a REAL
//! [`nuncio_contacts::CardDavClient`] from the persisted collection URL, issues
//! a genuine CardDAV `REPORT` against WireMock, and persists the fetched
//! contacts.
//!
//! This proves the production wiring end to end while remaining fully offline:
//! WireMock stands in for the remote CardDAV 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, AddAccountRequest, ContactsSyncRequest,
ListContactsRequest,
};
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-carddav-e2e-1";
const ACCOUNT_EMAIL: &str = "james.maes@nuncio.mx";
const KEYRING_KEY: &str = "nuncio/acct-carddav-e2e-1";
const CARDDAV_PASSWORD: &str = "wiremock-app-token";
/// A mail account added alongside the CardDAV one, to prove `Contacts/Sync`
/// refuses an account that is not a CardDAV account rather than silently
/// syncing nothing.
const MAIL_ACCOUNT_ID: &str = "acct-carddav-e2e-mail";
/// A canned CardDAV `multistatus` response with two vCards, mirroring the
/// transport-level fixture used by `nuncio-contacts`'s own WireMock test.
fn multistatus_body() -> String {
r#"<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:response>
<d:href>/addressbooks/contacts/james.vcf</d:href>
<d:propstat>
<d:prop>
<card:address-data>BEGIN:VCARD
VERSION:4.0
FN:Production CardDAV Maes
N:Maes;James;;;
ORG:KofTwentyTwo
TITLE:Founder
EMAIL;TYPE=WORK:james.maes@kof22.com
END:VCARD</card:address-data>
</d:prop>
</d:propstat>
</d:response>
<d:response>
<d:href>/addressbooks/contacts/alice.vcf</d:href>
<d:propstat>
<d:prop>
<card:address-data>BEGIN:VCARD
VERSION:4.0
FN:Production CardDAV Dev
EMAIL:alice@nuncio.mx
END:VCARD</card:address-data>
</d:prop>
</d:propstat>
</d:response>
</d:multistatus>"#
.to_string()
}
/// Boots the real daemon gRPC server with DEFAULT overrides (no injected
/// contacts 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("carddav_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(),
CalendarEngineOverrides::default(),
// No injected contacts backend: exercise the real production path.
ContactsEngineOverrides::default(),
AccountsEngineOverrides::default(),
)
.await;
});
(addr, token, dir)
}
/// The full offline proof of the production CardDAV sync path: configure a
/// CardDAV account over the real `Accounts` API, then `Contacts/Sync` builds a
/// real `CardDavClient` and syncs contacts fetched from WireMock into the
/// store, immediately visible to `ListContacts` -- all over the authenticated
/// gRPC API.
#[tokio::test]
async fn carddav_production_sync_builds_real_client_and_persists_contacts() {
let mock_server = MockServer::start().await;
Mock::given(method("REPORT"))
.and(path("/addressbooks/contacts/"))
.respond_with(ResponseTemplate::new(207).set_body_string(multistatus_body()))
.mount(&mock_server)
.await;
let collection_url = format!("{}/addressbooks/contacts/", 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 CardDAV account over the Accounts API ----
// The password travels to the daemon ONLY on this RPC and is written only
// to the mock vault; the sync path below reads it back from there.
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 Address Book".to_string(),
email_address: ACCOUNT_EMAIL.to_string(),
keyring_secret_key: KEYRING_KEY.to_string(),
sync_interval: Some(nuncio_proto::time::duration_from_secs(300)),
filters_enabled: false,
transport: Some(nuncio_proto::v1::account_config::Transport::Carddav(
nuncio_proto::v1::DavTransport {
collection_url: collection_url.clone(),
},
)),
}),
password: CARDDAV_PASSWORD.to_string(),
})
.await
.expect("CardDAV account is accepted and persisted");
// A non-CardDAV account, so the "not a CardDAV account" assertion below
// exercises a real, configured account rather than a missing one.
accounts_client
.add_account(AddAccountRequest {
config: Some(AccountConfigProto {
id: MAIL_ACCOUNT_ID.to_string(),
name: "Work Mail".to_string(),
email_address: ACCOUNT_EMAIL.to_string(),
keyring_secret_key: "nuncio/acct-carddav-e2e-mail".to_string(),
sync_interval: Some(nuncio_proto::time::duration_from_secs(300)),
filters_enabled: false,
transport: Some(nuncio_proto::v1::account_config::Transport::ImapSmtp(
nuncio_proto::v1::ImapSmtpTransport {
imap_host: "127.0.0.1".to_string(),
imap_port: 1143,
imap_tls_mode: nuncio_proto::v1::TlsMode::Plain.into(),
smtp_host: "127.0.0.1".to_string(),
smtp_port: 1025,
smtp_tls_mode: nuncio_proto::v1::TlsMode::Plain.into(),
},
)),
}),
password: "mail-password".to_string(),
})
.await
.expect("mail account is accepted and persisted");
// ---- Contacts/Sync runs the PRODUCTION path (no injected backend) ----
let mut contacts_client = nuncio_proto::client::connect_contacts(&addr_str, &token)
.await
.expect("contacts client connects");
let sync_response = contacts_client
.sync(ContactsSyncRequest {
account_id: ACCOUNT_ID.to_string(),
})
.await
.expect("production CardDAV sync succeeds")
.into_inner();
assert_eq!(sync_response.synced_count, 2);
// Immediately visible: `Sync` awaits full fetch-and-persist completion.
let list_response = contacts_client
.list_contacts(ListContactsRequest {
account_id: ACCOUNT_ID.to_string(),
page_size: 0,
page_token: String::new(),
})
.await
.expect("list_contacts succeeds")
.into_inner();
assert_eq!(list_response.contacts.len(), 2);
let names: Vec<&str> = list_response
.contacts
.iter()
.map(|c| c.display_name.as_str())
.collect();
assert!(names.contains(&"Production CardDAV Maes"));
assert!(names.contains(&"Production CardDAV Dev"));
// ---- A configured account that is NOT CardDAV fails honestly ----
let not_carddav = contacts_client
.sync(ContactsSyncRequest {
account_id: MAIL_ACCOUNT_ID.to_string(),
})
.await
.expect_err("a sync targeting a non-CardDAV account must fail honestly");
assert_eq!(not_carddav.code(), Code::FailedPrecondition);
assert!(not_carddav.message().contains(MAIL_ACCOUNT_ID));
// ---- An account with no CardDAV configuration at all fails honestly ----
let missing = contacts_client
.sync(ContactsSyncRequest {
account_id: "acct-does-not-exist".to_string(),
})
.await
.expect_err("a sync targeting an unconfigured account must fail honestly");
assert_eq!(missing.code(), Code::FailedPrecondition);
// ---- An unauthenticated call to Contacts is rejected ----
let mut unauthenticated_client =
nuncio_proto::v1::contacts_client::ContactsClient::connect(format!("http://{addr_str}"))
.await
.expect("client connects at the transport level without any auth");
let err = unauthenticated_client
.list_contacts(ListContactsRequest {
account_id: ACCOUNT_ID.to_string(),
page_size: 0,
page_token: String::new(),
})
.await
.expect_err("missing bearer token must be rejected");
assert_eq!(err.code(), Code::Unauthenticated);
}